Compare commits
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"env": {
|
||||
"PQL_VAULT": "/mnt/media/Projects/tatlock-ui"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pql)",
|
||||
"Bash(pql *)",
|
||||
"Bash(/home/jpmschweitzer/.local/bin/pql:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(flutter test:*)",
|
||||
"Bash(flutter analyze:*)",
|
||||
"Bash(flutter pub get:*)",
|
||||
"Bash(flutter pub outdated:*)",
|
||||
"Bash(flutter build web:*)",
|
||||
"Bash(dart analyze:*)",
|
||||
"Bash(dart format:*)",
|
||||
"Bash(docker logs tatlock-ui:*)",
|
||||
"Bash(curl -sI http://localhost:9999/*)",
|
||||
"mcp__chrome-devtools__take_screenshot",
|
||||
"mcp__puppeteer__puppeteer_evaluate",
|
||||
"mcp__chrome-devtools__navigate_page",
|
||||
"mcp__chrome-devtools__take_snapshot"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
|
||||
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
|
||||
"Bash(chmod -R 777 *)",
|
||||
"Bash(chmod 777 *)",
|
||||
"Bash(dart pub publish*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(find * -delete*)",
|
||||
"Bash(find * -exec*)",
|
||||
"Bash(flutter pub publish*)",
|
||||
"Bash(git * add --all*)",
|
||||
"Bash(git * add -A*)",
|
||||
"Bash(git * add .)",
|
||||
"Bash(git * branch -D *)",
|
||||
"Bash(git * checkout -- *)",
|
||||
"Bash(git * clean -fd*)",
|
||||
"Bash(git * clean -fdx*)",
|
||||
"Bash(git * commit --no-verify*)",
|
||||
"Bash(git * merge --no-ff*)",
|
||||
"Bash(git * push --force*)",
|
||||
"Bash(git * push -f*)",
|
||||
"Bash(git * reset --hard*)",
|
||||
"Bash(git * restore .*)",
|
||||
"Bash(git add --all*)",
|
||||
"Bash(git add -A*)",
|
||||
"Bash(git add .)",
|
||||
"Bash(git branch -D *)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git clean -fd*)",
|
||||
"Bash(git clean -fdx*)",
|
||||
"Bash(git commit --no-verify*)",
|
||||
"Bash(git merge --no-ff*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(rm -rf $HOME)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(su *)",
|
||||
"Bash(sudo *)",
|
||||
"Bash(toj)",
|
||||
"Bash(toj:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.pql/changelog/*.sql merge=union
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.schweitz.net
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
git.schweitz.net/jpmschweitzer/tatlock-ui:latest
|
||||
git.schweitz.net/jpmschweitzer/tatlock-ui:${{ github.ref_name }}
|
||||
|
||||
- name: Trigger Watchtower update
|
||||
if: success()
|
||||
run: |
|
||||
curl -sf -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
|
||||
http://watchtower:8080/v1/update
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trigger only. The checks live in the Makefile, where they can be read, run by
|
||||
# hand (`make pre-push`), and changed under review.
|
||||
#
|
||||
# This file is identical in every repo in this workspace, deliberately: the call
|
||||
# surface is the same everywhere even though what each gate runs is not, so
|
||||
# nobody has to read a repo to find out how to check it (D-27).
|
||||
#
|
||||
# Enable per clone with: git config core.hooksPath .githooks
|
||||
# Never bypass with --no-verify. Suppress a specific finding deliberately
|
||||
# instead, with a reason — see `make pre-push`.
|
||||
set -euo pipefail
|
||||
exec make -C "$(git rev-parse --show-toplevel)" pre-push
|
||||
+33
-6
@@ -12,20 +12,21 @@ pubspec.lock
|
||||
*.freezed.dart
|
||||
*.gr.dart
|
||||
*.mocks.dart
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
# Keep version.g.dart - it's generated but should be committed
|
||||
# so CI/CD builds have version info without running the generator
|
||||
# Other *.g.dart files (from json_serializable, etc.) are ignored
|
||||
# All generated *.g.dart files (from json_serializable, riverpod, version_builder)
|
||||
# These are regenerated by build_runner during CI/CD builds
|
||||
lib/**/*.g.dart
|
||||
!lib/version.g.dart
|
||||
|
||||
# Generated health.json (regenerated by tool/generate_health_json.dart during build)
|
||||
web/health.json
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.vscode/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -118,3 +119,29 @@ secrets/
|
||||
|
||||
# Uploads (reference images, not tracked)
|
||||
uploads/
|
||||
logs/
|
||||
.vscode/launch.json
|
||||
|
||||
# Claude Code user-specific settings.
|
||||
#
|
||||
# WARNING: this file is currently TRACKED, so this rule does NOTHING yet. Git
|
||||
# applies ignore rules only to untracked paths; edits to a tracked file still
|
||||
# show in status and still get committed. It takes effect only after
|
||||
# git rm --cached .claude/settings.local.json
|
||||
# which is a history decision, deliberately left out of the 2026-08-09
|
||||
# normalization pass. Contents are benign - a 4-entry permission allow list, no
|
||||
# env block, no secrets - so this is hygiene, not an incident.
|
||||
#
|
||||
# Also: plain `git check-ignore` prints nothing for this path because it consults
|
||||
# the index. Use `git check-ignore --no-index` to confirm the rule itself matches.
|
||||
.claude/settings.local.json
|
||||
.pql/*
|
||||
!.pql/changelog/
|
||||
|
||||
# pql shims planted by `pql init` into the dir core.hooksPath points at.
|
||||
# Per-clone: each embeds the absolute path of the pql binary that planted it.
|
||||
# Only .githooks/pre-push is shared.
|
||||
.githooks/pre-commit
|
||||
.githooks/post-merge
|
||||
.githooks/post-checkout
|
||||
.githooks/post-rewrite
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Changelog format marker, written by pql. Comments only: this file
|
||||
-- is never executed — Import descends into the per-table directories
|
||||
-- and does not read the changelog root.
|
||||
--
|
||||
-- A changelog carrying no marker is format 1, the shape that existed
|
||||
-- before formats were versioned. An older format is migrated forward
|
||||
-- by `pql plan upgrade` (and automatically from the post-merge hook);
|
||||
-- a newer one is refused rather than replayed under rules this binary
|
||||
-- does not know. See D-28 and docs/versions.md.
|
||||
-- pql:changelog_format: 2.0.0
|
||||
-- pql:written_by: 2.2.0
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,24 @@
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'description', NULL, '`.claude/settings.local.json` is committed to this repo. It is the per-clone local overrides file — the documented home for personal settings and for credentials — and it does not belong in version control at all.
|
||||
|
||||
STATE, verified 2026-08-11 rather than assumed:
|
||||
- the file is tracked, with 2 commits touching it (both 2025-12-31)
|
||||
- `.gitignore` already lists it at line 137, and line 130 carries the exact `git rm --cached` command in a comment
|
||||
- gitignore does not apply to files git already tracks, which is why the rule has been inert since it was added
|
||||
- this is the ONLY repo in the workspace where the file is tracked; desklock, library-desk and tatlock all ignore it correctly
|
||||
|
||||
NO CREDENTIALS WERE EVER COMMITTED. Both committed versions contain a `permissions` section and nothing else — zero env keys, checked at each commit rather than only at HEAD. This is a loaded trap, not a leak: the next person who adds an env key to this file commits a secret, and nothing will stop them because the ignore rule cannot fire on a tracked path.
|
||||
|
||||
Worth recording because it has already cost time once: a survey agent previously reported credentials committed across nine repos. Traced to source, it was this one file holding a four-entry permission list and no secrets. The finding was wrong and the file is still tracked, so the same false alarm is available to the next person who greps for it.
|
||||
|
||||
THE FOUR RULES IN IT ARE THE ONLY REASON TO PAUSE. They allow browser-automation MCP tools:
|
||||
mcp__chrome-devtools__take_screenshot, take_snapshot, navigate_page
|
||||
mcp__puppeteer__puppeteer_evaluate
|
||||
|
||||
Untracking removes them from anyone who clones. Two ways to go, and this wants deciding rather than defaulting:
|
||||
- they are genuinely personal (one person''s browser tooling) — untrack and let each clone re-grant
|
||||
- they are useful to anyone working on this UI — move them into `.claude/settings.json`, which is committed by design and already carries 16 allow and 43 deny rules
|
||||
|
||||
The second reading looks likelier for a front-end repo where visual verification is routine, but it is a judgement about how people work here, not something the file can answer.
|
||||
|
||||
FIX: `git rm --cached .claude/settings.local.json` and commit. The file stays on disk, so nobody loses their local settings; it simply stops being shared. Decide the four rules first, or they vanish quietly.', NULL, '2026-08-11 19:21:23', '2026-08-11 19:21:23.594', '2026-08-11 19:21:23.594', NULL, '6ae54cdfcaa87232e3074a23b88d1b82', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'status', 'backlog', 'done', NULL, '2026-08-11 19:27:15', '2026-08-11 19:27:15.269', '2026-08-11 19:27:15.269', NULL, 'e7784de0ed56f0679d0823a163b008bc', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1 @@
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'T-1', '2026-08-11 19:21:23.466', '2026-08-11 19:21:23.466', NULL, '953ce74627a5dd3cc3f2c4e56e6fc6aa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,47 @@
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'bug', NULL, '.claude/settings.local.json is tracked in git and should not be', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-08-11 19:21:23.458', '2026-08-11 19:21:23.458', NULL, 'b68f6240b59c932e1b81744ce2e9f054', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'bug', NULL, '.claude/settings.local.json is tracked in git and should not be', '`.claude/settings.local.json` is committed to this repo. It is the per-clone local overrides file — the documented home for personal settings and for credentials — and it does not belong in version control at all.
|
||||
|
||||
STATE, verified 2026-08-11 rather than assumed:
|
||||
- the file is tracked, with 2 commits touching it (both 2025-12-31)
|
||||
- `.gitignore` already lists it at line 137, and line 130 carries the exact `git rm --cached` command in a comment
|
||||
- gitignore does not apply to files git already tracks, which is why the rule has been inert since it was added
|
||||
- this is the ONLY repo in the workspace where the file is tracked; desklock, library-desk and tatlock all ignore it correctly
|
||||
|
||||
NO CREDENTIALS WERE EVER COMMITTED. Both committed versions contain a `permissions` section and nothing else — zero env keys, checked at each commit rather than only at HEAD. This is a loaded trap, not a leak: the next person who adds an env key to this file commits a secret, and nothing will stop them because the ignore rule cannot fire on a tracked path.
|
||||
|
||||
Worth recording because it has already cost time once: a survey agent previously reported credentials committed across nine repos. Traced to source, it was this one file holding a four-entry permission list and no secrets. The finding was wrong and the file is still tracked, so the same false alarm is available to the next person who greps for it.
|
||||
|
||||
THE FOUR RULES IN IT ARE THE ONLY REASON TO PAUSE. They allow browser-automation MCP tools:
|
||||
mcp__chrome-devtools__take_screenshot, take_snapshot, navigate_page
|
||||
mcp__puppeteer__puppeteer_evaluate
|
||||
|
||||
Untracking removes them from anyone who clones. Two ways to go, and this wants deciding rather than defaulting:
|
||||
- they are genuinely personal (one person''s browser tooling) — untrack and let each clone re-grant
|
||||
- they are useful to anyone working on this UI — move them into `.claude/settings.json`, which is committed by design and already carries 16 allow and 43 deny rules
|
||||
|
||||
The second reading looks likelier for a front-end repo where visual verification is routine, but it is a judgement about how people work here, not something the file can answer.
|
||||
|
||||
FIX: `git rm --cached .claude/settings.local.json` and commit. The file stays on disk, so nobody loses their local settings; it simply stops being shared. Decide the four rules first, or they vanish quietly.', 'backlog', 'high', NULL, NULL, NULL, '2026-08-11 19:21:23.458', '2026-08-11 19:21:23.593', NULL, 'dad1d9ab6415c597def74a213e0d914e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ4HDS0AGMK8R3WS0RY7BMJW', 'bug', NULL, '.claude/settings.local.json is tracked in git and should not be', '`.claude/settings.local.json` is committed to this repo. It is the per-clone local overrides file — the documented home for personal settings and for credentials — and it does not belong in version control at all.
|
||||
|
||||
STATE, verified 2026-08-11 rather than assumed:
|
||||
- the file is tracked, with 2 commits touching it (both 2025-12-31)
|
||||
- `.gitignore` already lists it at line 137, and line 130 carries the exact `git rm --cached` command in a comment
|
||||
- gitignore does not apply to files git already tracks, which is why the rule has been inert since it was added
|
||||
- this is the ONLY repo in the workspace where the file is tracked; desklock, library-desk and tatlock all ignore it correctly
|
||||
|
||||
NO CREDENTIALS WERE EVER COMMITTED. Both committed versions contain a `permissions` section and nothing else — zero env keys, checked at each commit rather than only at HEAD. This is a loaded trap, not a leak: the next person who adds an env key to this file commits a secret, and nothing will stop them because the ignore rule cannot fire on a tracked path.
|
||||
|
||||
Worth recording because it has already cost time once: a survey agent previously reported credentials committed across nine repos. Traced to source, it was this one file holding a four-entry permission list and no secrets. The finding was wrong and the file is still tracked, so the same false alarm is available to the next person who greps for it.
|
||||
|
||||
THE FOUR RULES IN IT ARE THE ONLY REASON TO PAUSE. They allow browser-automation MCP tools:
|
||||
mcp__chrome-devtools__take_screenshot, take_snapshot, navigate_page
|
||||
mcp__puppeteer__puppeteer_evaluate
|
||||
|
||||
Untracking removes them from anyone who clones. Two ways to go, and this wants deciding rather than defaulting:
|
||||
- they are genuinely personal (one person''s browser tooling) — untrack and let each clone re-grant
|
||||
- they are useful to anyone working on this UI — move them into `.claude/settings.json`, which is committed by design and already carries 16 allow and 43 deny rules
|
||||
|
||||
The second reading looks likelier for a front-end repo where visual verification is routine, but it is a judgement about how people work here, not something the file can answer.
|
||||
|
||||
FIX: `git rm --cached .claude/settings.local.json` and commit. The file stays on disk, so nobody loses their local settings; it simply stops being shared. Decide the four rules first, or they vanish quietly.', 'done', 'high', NULL, NULL, NULL, '2026-08-11 19:21:23.458', '2026-08-11 19:27:15.269', NULL, 'be5da7217e2daee70cd57471ffd934fc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Flutter Web (Chrome)",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"args": [
|
||||
"-d",
|
||||
"chrome",
|
||||
"--web-port=8080"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Flutter Web (Chrome) - Profile",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"flutterMode": "profile",
|
||||
"args": [
|
||||
"-d",
|
||||
"chrome",
|
||||
"--web-port=8080"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Flutter Web (Chrome) - Release",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"flutterMode": "release",
|
||||
"args": [
|
||||
"-d",
|
||||
"chrome",
|
||||
"--web-port=8080"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
# LLM Agent Instructions
|
||||
|
||||
This document contains instructions and documentation references for AI assistants working with this codebase.
|
||||
|
||||
> **📖 Important**: Before working on this project, read [PHILOSOPHY.md](PHILOSOPHY.md) to understand the system vision, architectural patterns, and design goals. All development should work towards realizing those patterns.
|
||||
# AGENTS.md
|
||||
|
||||
> **Start every session by reading this file.**
|
||||
> This file outlines the operational protocols, coding standards, and architectural decisions for this Flutter project.
|
||||
|
||||
## 1. Agent Operational Protocols
|
||||
|
||||
### 🧠 Work Patterns (Plan-Act-Reflect)
|
||||
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
|
||||
* **Act:** Execute the changes in small, atomic steps.
|
||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
||||
|
||||
### 🌐 Internal Service Access
|
||||
* **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO
|
||||
* Example: `curl http://localhost:3002/jpmschweitzer/library-desk/raw/branch/main/README.md`
|
||||
* Public repos are readable without authentication
|
||||
* Related repos: , `core-api`, `tatlock`, `library-desk`, `scheduler`, `portainer-core`
|
||||
|
||||
### 🐳 Deployment & Infrastructure
|
||||
* **Full stack documentation**: Available in the `portainer-core` repo
|
||||
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
|
||||
* Contains: All service ports, URLs, Redis DB allocations, external domains
|
||||
* **Tatlock deployment**:
|
||||
* LAN: `http://192.168.86.149:8000`
|
||||
* External: `tatlock.schweitz.net` (behind Authentik SSO)
|
||||
* Redis DBs: 1 (memory), 6 (benchmarks)
|
||||
* **Health check**: `curl http://192.168.86.149:8000/health`
|
||||
|
||||
### 🛡️ Git Discipline
|
||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||
* `feat: add user login endpoint`
|
||||
* `fix: resolve database connection timeout`
|
||||
* `refactor: split monolith dependency file`
|
||||
* **Atomic Commits:** Keep commits small. One logical change = one commit.
|
||||
* **Version Tagging:** Every version increment (major.minor.patch, not build count) must have a corresponding git tag.
|
||||
* Format: `v{major}.{minor}.{patch}` (e.g., `v0.3.0`)
|
||||
* Tag after updating `pubspec.yaml` version and CHANGELOG
|
||||
* Push tags with `git push --tags`
|
||||
|
||||
### 🧪 Testing Requirements
|
||||
* **Always add tests for new code before committing.** No exceptions.
|
||||
* Tests should cover the happy path and key edge cases.
|
||||
* Run `flutter test` before committing to ensure all tests pass.
|
||||
* For widgets: use widget tests. For business logic: use unit tests.
|
||||
* Code coverage should not decrease with new commits.
|
||||
|
||||
### 📝 Changelog Maintenance
|
||||
* **Update `CHANGELOG.md`** with every user-facing change.
|
||||
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
||||
+507
@@ -7,6 +7,513 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.7.1] - 2026-07-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web build failure with dio >= 5.8: `DioExceptionType` switch is now
|
||||
exhaustive across dio versions (CI resolves dependencies fresh)
|
||||
|
||||
## [1.7.0] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Default Core API and Tatlock API URLs now use the public domains
|
||||
`https://api.schweitz.net` and `https://tatlock.schweitz.net` (previously LAN
|
||||
`http://192.168.86.149:8083`/`:8000`), so default builds require OIDC auth;
|
||||
override via `--dart-define` for direct LAN development
|
||||
|
||||
### Removed
|
||||
|
||||
- Netdata and Cloud IDE (code-server) quick links — both services are
|
||||
decommissioned; also dropped the unused `portainerUrl`/`netdataUrl`
|
||||
config constants
|
||||
|
||||
## [1.6.0] - 2026-01-08
|
||||
|
||||
### Added
|
||||
|
||||
- **News Ticker Widget** - Scrolling news headlines on dashboard
|
||||
- Full-width ticker between Welcome card and System Stats
|
||||
- Horizontal auto-scrolling at 40px/second with seamless looping
|
||||
- Fetches headlines from `/tools/news` endpoint
|
||||
- Placeholder headlines shown when no data (italic, muted style)
|
||||
- Auto-refresh every 30 minutes
|
||||
- News data model (`NewsData`, `NewsHeadline`) with Freezed
|
||||
- News datasource calling `GET /tools/news`
|
||||
- News provider with `hasNews` helper
|
||||
|
||||
## [1.5.9] - 2026-01-08
|
||||
|
||||
### Changed
|
||||
|
||||
- **Aligned horizon line across all widgets** - Consistent visual baseline at 50px from bottom
|
||||
- Weather and Air Quality dividers now align with Sun Position horizon line
|
||||
- Forecast card bottoms align with the same horizon
|
||||
- Creates unified visual rhythm across all environment cards
|
||||
|
||||
## [1.5.8] - 2026-01-08
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bottom-aligned widget content** - All environment widgets now align content from the bottom
|
||||
- Creates consistent visual baseline across Sun Position, Weather, Air Quality, and Forecast cards
|
||||
- Footers (weather details, pollutants) sit at the same level across cards
|
||||
|
||||
### Added
|
||||
|
||||
- **Wind direction in Weather** - Wind chip now shows direction (e.g., "SE 14 km/h")
|
||||
|
||||
## [1.5.7] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Environment widget alignment** - Consistent card heights across all environment widgets
|
||||
- Added `ConstrainedBox(minHeight: 170)` to Weather, Air Quality, and Forecast widgets
|
||||
- All cards now match Sun Position widget height when displaying data
|
||||
|
||||
- **Sun position night labels** - Swap sunrise/sunset labels at night
|
||||
- During day: Sunrise on left, Sunset on right (day arc)
|
||||
- At night: Sunset on left, Sunrise on right (night arc)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Weather widget header** - Changed from location name to "Weather" for consistency
|
||||
- Location now displayed in content area below temperature
|
||||
|
||||
## [1.5.6] - 2026-01-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **Environment user logging** - Now logs API user only once per session instead of on every refresh
|
||||
|
||||
## [1.5.5] - 2026-01-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sun position arc overflow** - Arc now constrained to fit within card boundaries
|
||||
- Prevents arc and sun/moon from overflowing on wider displays
|
||||
- Scales radius down when arc height exceeds available space
|
||||
|
||||
### Added
|
||||
|
||||
- **Debug logging for environment user** - Logs authenticated user on environment data load
|
||||
|
||||
## [1.5.4] - 2026-01-07
|
||||
|
||||
### Added
|
||||
|
||||
- User display in environment section header (reverted in 1.5.5)
|
||||
|
||||
## [1.5.3] - 2026-01-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sun position arc redesign** - Arc now touches horizon at sunrise/sunset points
|
||||
- Arc geometry uses chord-radius calculation for proper horizon intersection
|
||||
- Sunrise/sunset icons integrated at horizon endpoints (no duplicate labels)
|
||||
- Daylight duration centered below arc
|
||||
- Increased card height 20% for better arc visibility (140px → 170px)
|
||||
- **Visual balance improvements** - Sunrise/sunset labels raised 10px, daylight label at bottom
|
||||
|
||||
## [1.5.2] - 2026-01-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proportional sun/moon arc visualization** - Arc angle now proportional to day/night duration
|
||||
- Day arc: spans (daylight hours / 24) × 360° above horizon
|
||||
- Night arc: spans (night hours / 24) × 360° with moon traversal
|
||||
- Horizon points represent sunrise/sunset times
|
||||
- Gradient colors: yellow/orange for day, blue/indigo for night
|
||||
- **Sun position widget now updates every 10 minutes** - Position aligned to clock intervals (0/10/20/30/40/50)
|
||||
- Converted to StatefulWidget with timer-based updates
|
||||
- Shows default 7am/5pm sunrise/sunset when API data unavailable (asymmetric for visual effect)
|
||||
- Always displays times and daylight duration (no more "--:--")
|
||||
- **Environment widgets consistent height** - All cards maintain same height in "no data" state
|
||||
|
||||
## [1.5.1] - 2026-01-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **Environment widgets layout redesign** - All 4 widgets always visible with responsive layout
|
||||
- Desktop (>900px): 4-in-a-row with 30/20/20/30 width distribution (Sun | Weather | Air Quality | Forecast)
|
||||
- Tablet (600-900px): 2x2 grid layout
|
||||
- Mobile (<600px): Stacked vertically
|
||||
- Widgets now show "No data available" state instead of being hidden or using mock data
|
||||
- Sun position widget now calculates position from system clock
|
||||
- Defaults to 6am/6pm (12-hour day/night cycles) when API sun times unavailable
|
||||
- Environment data refresh interval changed from 5 minutes to 1 hour
|
||||
|
||||
## [1.5.0] - 2026-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Dynamic Environment Widgets** - Live weather, sun position, and forecast data from Qdrant
|
||||
- `SunPositionWidget` - Animated semicircle arc showing sun/moon position based on current time
|
||||
- Gradient colors: yellow for daytime, orange for sunrise/sunset, blue for night
|
||||
- Displays sunrise, sunset times and daylight duration
|
||||
- `WeatherWidget` - Current temperature, conditions, humidity from API
|
||||
- `ForecastWidget` - Multi-day weather forecast with conditions icons
|
||||
- `AirQualityWidget` - AQI display (only shown when data available)
|
||||
- Environment data provider with auto-refresh every 5 minutes
|
||||
- Environment datasource calling `GET /tools/environment`
|
||||
- Freezed models for environment data (weather, forecast, sun times, air quality)
|
||||
- Comprehensive widget tests for environment section
|
||||
|
||||
### Changed
|
||||
|
||||
- Dashboard layout now displays dynamic environment data instead of static widgets
|
||||
- Weather and air quality widgets accept optional API data parameters
|
||||
|
||||
## [1.4.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
- **Decentralized Room Registry** - Each room registers itself with central registry
|
||||
- `RoomDefinition` class with id, label, icons, routes, and permissions
|
||||
- `RoomRegistry` singleton for managing all rooms
|
||||
- Dynamic navigation tabs built from registry
|
||||
- Settings dropdown builds from available rooms
|
||||
- Permission-based room filtering support
|
||||
- **Media Room** - New placeholder room for future media management features
|
||||
- **Parlor** - Now a proper feature folder with router registration
|
||||
|
||||
### Changed
|
||||
- Room navigation is now fully dynamic via registry
|
||||
- `top_header_bar.dart` uses `roomRegistry.all` instead of hardcoded list
|
||||
- `app_scaffold.dart` uses registry for route matching and navigation
|
||||
- Settings page dropdown populated from `roomRegistry.all`
|
||||
- Routers moved to per-room pattern:
|
||||
- `lib/features/front_hall/router.dart` (new)
|
||||
- `lib/features/parlor/router.dart` (new)
|
||||
- `lib/features/media_room/router.dart` (new)
|
||||
- Existing `control_room/router.dart` and `security/router.dart` now register with registry
|
||||
- Documentation updated in ARCHITECTURE.md with Room Registry Pattern section
|
||||
|
||||
### Removed
|
||||
- Hardcoded room lists in `top_header_bar.dart` and `app_scaffold.dart`
|
||||
- `_PlaceholderPage` widget in `app_router.dart` (each room has its own page)
|
||||
|
||||
## [1.3.1] - 2026-01-05
|
||||
|
||||
### Fixed
|
||||
- Default room preference now applies on app load
|
||||
- Root `/` redirects to user's preferred default room
|
||||
- Front Hall moved to `/front-hall` route (was `/`)
|
||||
|
||||
## [1.3.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
- **URL deep-linking for DataGrids** - State is now reflected in URL query parameters
|
||||
- `?search=` - DataGrid search query
|
||||
- `?sort=` - Column ID for sorting
|
||||
- `?order=desc` - Sort direction
|
||||
- `?id=` - Opened document ID (proxy hosts page)
|
||||
- `PageUrlState` utility class (`lib/routing/url_state.dart`) for URL ↔ state serialization
|
||||
- Browser URL updates via `replaceState` without triggering GoRouter rebuilds
|
||||
- `id` field added to `DataGridColumn` for unique column identification in URLs
|
||||
- `FilterPanelSemantics` class for filter panel semantic IDs
|
||||
- TESTING.md documentation for semantic widgets and automation testing
|
||||
|
||||
### Changed
|
||||
- `idSelector` in DataGridController now returns `String` (was `Object`) for URL compatibility
|
||||
- All DataGrid pages now support URL deep-linking:
|
||||
- Containers list (`/control-room/containers`)
|
||||
- Proxy hosts (`/control-room/proxy-hosts`)
|
||||
- Users (`/security/users`)
|
||||
- Groups (`/security/groups`)
|
||||
- Router passes `GoRouterState` to pages for query parameter access
|
||||
|
||||
## [1.2.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
- **Semantic labels for UI automation** (`lib/core/semantics/`)
|
||||
- `semantic_ids.dart` - Centralized semantic identifier constants
|
||||
- `semantic_widget.dart` - Helper widget and extension for adding semantics
|
||||
- Enables browser automation tools (Puppeteer, WebDriver) via accessibility tree
|
||||
- Semantic IDs added to:
|
||||
- Profile dropdown button and menu items (theme options, settings, logout)
|
||||
- Room navigation tabs (Front Hall, Control Room, Security, Parlor)
|
||||
- NavPanel items (sidebar navigation)
|
||||
- `SemanticsBinding.instance.ensureSemantics()` enabled on web builds
|
||||
|
||||
## [1.1.16] - 2026-01-05
|
||||
|
||||
### Fixed
|
||||
- Theme toggle causing logout due to AuthInterceptor auto-signout on 401
|
||||
- Removed aggressive `signOut()` call in `AuthInterceptor.onError`
|
||||
- 401 errors now propagate to calling code for graceful handling
|
||||
- Preferences API 401 no longer triggers full logout redirect
|
||||
|
||||
## [1.1.15] - 2026-01-05
|
||||
|
||||
### Fixed
|
||||
- Theme toggle causing auth issues due to AuthNotifier auto-dispose
|
||||
- Applied `@persistentRiverpod` annotation to AuthNotifier
|
||||
- AuthProvider now persists for app lifetime, preventing rebuild on theme change
|
||||
|
||||
## [1.1.14] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Theme toggle causing auth issues due to ThemeProvider auto-dispose
|
||||
- Added `@persistentRiverpod` annotation for providers that need keepAlive
|
||||
- ThemeProvider now persists for app lifetime
|
||||
|
||||
### Added
|
||||
- `@persistentRiverpod` annotation in `core/providers/annotations.dart`
|
||||
- Reusable annotation for providers that should not auto-dispose
|
||||
- Documented in ARCHITECTURE.md
|
||||
|
||||
## [1.1.13] - 2026-01-04
|
||||
|
||||
### Added
|
||||
- Settings page with Appearance, Navigation, and Account sections
|
||||
- Theme toggle in user profile dropdown (System/Light/Dark)
|
||||
- Theme syncs with API preferences on login
|
||||
- Default room preference syncs with backend
|
||||
|
||||
### Changed
|
||||
- Theme changes now persist to both local storage and API
|
||||
|
||||
## [1.1.12] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- Control Room navigation reorganized:
|
||||
- New "Stack" section with Containers and Proxy Hosts
|
||||
- New "Data Management" section with PostgreSQL, Redis, Qdrant, Neo4j placeholders
|
||||
- Removed: Networks, Volumes, Images (Portainer) and Redirections, Streams, Certificates (NPM)
|
||||
|
||||
## [1.1.11] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- API client providers now use `keepAlive: true` to prevent Ref invalidation
|
||||
- Fixes "DioException [unknown]: null" error on /security/users and other API pages
|
||||
- AuthInterceptor's stored Ref was becoming invalid when provider auto-disposed
|
||||
|
||||
## [1.1.10] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- Removed page swipe transitions - all navigation is now instant (NoTransitionPage)
|
||||
|
||||
## [1.1.9] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- Moved health check to `/health` directory - URL is now `/health` instead of `/health.html`
|
||||
- Enables NPM forward auth path exclusion for health endpoint
|
||||
|
||||
## [1.1.8] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- Dark background (`#1a1a2e`) on web/index.html to prevent white flash during auth redirects
|
||||
|
||||
## [1.1.7] - 2026-01-04
|
||||
|
||||
### Removed
|
||||
- Removed `/callback` route from Flutter router - AuthController handles callback in main() before app starts
|
||||
- Removed `_OidcCallbackPage` widget - no visible auth UI needed
|
||||
|
||||
## [1.1.6] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- **Auth moved to standalone controller** - Handles OIDC completely outside Riverpod
|
||||
- New `AuthController` runs in `main()` before `runApp()` - avoids provider lifecycle issues
|
||||
- Handles callback, token exchange, and /auth/sync before app starts
|
||||
- If auth not ready (redirecting), app doesn't start at all
|
||||
- `AuthProvider` now just loads stored tokens (no async OIDC logic)
|
||||
- Fixes "Cannot use Ref after disposed" errors from autoDispose providers
|
||||
|
||||
## [1.1.5] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Race condition in OIDC callback: AuthProvider.build() was initiating silent OIDC while the callback page was processing, causing PKCE state to be cleared. Now skips silent OIDC when on `/callback` route.
|
||||
|
||||
## [1.1.4] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Silent OIDC fallback: when `prompt=none` fails with `login_required` (no Authentik session), automatically fall back to regular OIDC flow to show login UI
|
||||
|
||||
## [1.1.3] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- **Web auth uses silent OIDC with JWT Bearer tokens**
|
||||
- Uses `prompt=none` to silently obtain JWT when Authentik session exists (via NPM forward auth)
|
||||
- Flutter sends Bearer token to core-api instead of relying on forward auth cookies
|
||||
- Fixes cross-subdomain cookie issues between home.schweitz.net and api.schweitz.net
|
||||
- Callback now syncs with `/auth/sync` to get user profile and roles from core-api
|
||||
- API interceptor now adds Bearer token on web (previously skipped)
|
||||
|
||||
## [1.1.2] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- **Web auth simplified**: Skip Flutter OIDC on web - NPM forward auth handles it
|
||||
- NPM authenticates at proxy level before app loads
|
||||
- No more redundant OIDC redirect after NPM auth completes
|
||||
- Fixes "Cannot use Ref after disposed" error from conflicting auth flows
|
||||
- Mobile still uses Flutter OIDC flow
|
||||
|
||||
### Added
|
||||
- Logout now redirects to Authentik to end SSO session
|
||||
- Clears local tokens AND invalidates Authentik session
|
||||
- Uses OIDC end_session_endpoint from discovery document
|
||||
- Redirects back to app after Authentik logout completes
|
||||
|
||||
## [1.1.0] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- **Dockerfile rebuild fix**: Added `flutter clean` before build to prevent stale cached artifacts
|
||||
- VERSION build arg added for explicit cache busting
|
||||
- Reordered build steps: clean → pub get → build_runner → health.json → flutter build
|
||||
- Ensures deployed app always matches the version in health.json
|
||||
|
||||
### Fixed
|
||||
- Replaced deprecated `dart:html` with `package:web` in iframe_view_web.dart
|
||||
- Uses `web.HTMLIFrameElement` instead of `html.IFrameElement`
|
||||
- Fixes deprecation warnings for Flutter 3.x web builds
|
||||
|
||||
## [1.0.12] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- Removed login page - auth now auto-initiates from AppScaffold
|
||||
- No more redirect to /login, just auto-start OIDC if not authenticated
|
||||
- Shows loading screen during auth, error screen on failure with retry
|
||||
- Seamless experience when Authentik session exists
|
||||
|
||||
### Removed
|
||||
- Removed /login route and _LoginPage widget
|
||||
|
||||
## [1.0.11] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- Web auth now extracts user info directly from JWT instead of syncing with core-api
|
||||
- Eliminates CORS preflight issues with /auth/sync endpoint
|
||||
- Decodes JWT claims (name, email, groups) client-side
|
||||
- Bearer token will be used for API authentication
|
||||
|
||||
## [1.0.10] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Fixed OIDC callback route being redirected to login before processing
|
||||
- Moved callback route exception check BEFORE the auth redirect check in router
|
||||
- This was preventing token exchange from ever happening
|
||||
- Added favicon.ico to web root for proper browser tab icon display
|
||||
|
||||
## [1.0.9] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Fixed OIDC callback Riverpod state modification error
|
||||
- Deferred callback processing to `addPostFrameCallback` to avoid modifying state during widget build
|
||||
|
||||
## [1.0.8] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- Switched from hash-based URLs (`/#/login`) to path-based URLs (`/login`)
|
||||
- Required for OIDC callback to work correctly
|
||||
- Uses conditional import to avoid breaking mobile/desktop builds
|
||||
|
||||
## [1.0.7] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Fixed OIDC PKCE state loss across browser redirect
|
||||
- Code verifier and state now persist in sessionStorage instead of memory
|
||||
- Prevents "No code verifier" error after Authentik redirect
|
||||
|
||||
## [1.0.6] - 2026-01-04
|
||||
|
||||
### Fixed
|
||||
- Fixed version generation in CI/CD builds
|
||||
- Removed generated files (version.g.dart, health.json) from git tracking
|
||||
- These files are now regenerated from pubspec.yaml during Docker build
|
||||
|
||||
## [1.0.5] - 2026-01-04
|
||||
|
||||
### Changed
|
||||
- **Web authentication now uses OIDC** instead of NPM forward auth
|
||||
- Added `OidcServiceWeb` for browser redirect-based Authorization Code flow with PKCE
|
||||
- Added `/callback` route to handle Authentik redirect after login
|
||||
- Login page now shows "Sign in with Authentik" button for both web and mobile
|
||||
- Tokens stored in SharedPreferences and synced with core-api via `/auth/sync`
|
||||
- Added web utility functions (`web_utils.dart`) with conditional imports for non-web platforms
|
||||
- Added `crypto` and `web` packages for PKCE SHA-256 and browser API access
|
||||
|
||||
### Fixed
|
||||
- Removed cross-origin cookie dependency that caused authentication failures on web
|
||||
|
||||
## [1.0.4] - 2026-01-03
|
||||
|
||||
### Added
|
||||
- `health.json` generated at build time with app version info
|
||||
- `health.html` now displays version, title, and status from health.json
|
||||
|
||||
## [1.0.3] - 2026-01-03
|
||||
|
||||
### Fixed
|
||||
- Fixed auth endpoint path: `/auth/me` → `/auth/users/me`
|
||||
|
||||
## [1.0.2] - 2026-01-03
|
||||
|
||||
### Fixed
|
||||
- Production Docker build now uses correct API URLs
|
||||
- Added `--dart-define` flags for `CORE_API_URL` and `TATLOCK_API_URL`
|
||||
- This enables `requiresAuth=true` so authentication is actually triggered
|
||||
- Updated AGENTS.md with clear service port reference table
|
||||
|
||||
## [1.0.1] - 2026-01-03
|
||||
|
||||
### Fixed
|
||||
- Web authentication now works correctly with NPM forward auth
|
||||
- Dio client sends cookies with requests via `withCredentials: true`
|
||||
- Added platform-specific adapters (native vs web) for proper cookie handling
|
||||
|
||||
## [1.0.0] - 2026-01-03
|
||||
|
||||
### Added
|
||||
- **Authentication System** - Dual-flow auth supporting web (NPM forward auth) and mobile (OIDC)
|
||||
- `AuthState` model with roles, permissions, and user preferences
|
||||
- `AuthProvider` with automatic web session detection via `/auth/me`
|
||||
- Permission system with Domain/Action enums and hierarchical access levels
|
||||
- `PermissionGate` and `AdminGate` widgets for UI permission checks
|
||||
- `Role` model with `{domain}.{category}:{action}` format parsing
|
||||
- Route guards redirect unauthenticated users to login page
|
||||
- Login page with Authentik OAuth redirect
|
||||
- Mobile auth platform configuration (iOS URL schemes, Android AppAuth)
|
||||
- Comprehensive auth test suite (60 unit tests)
|
||||
|
||||
### Changed
|
||||
- API interceptor skips Bearer tokens on web (uses cookies via NPM forward auth)
|
||||
- Router integrates auth state for protected route access
|
||||
- **First stable release** - Core functionality complete for home lab dashboard
|
||||
|
||||
## [0.3.3] - 2026-01-03
|
||||
|
||||
### Added
|
||||
- Health check endpoint (`/health.html`) for Portainer container monitoring
|
||||
- Local search filtering in DataGrid (filters cached data client-side)
|
||||
- Container status badges now reflect health status (green=healthy, orange=unhealthy)
|
||||
- `ContainerHealth` enum for parsing Docker health status from status string
|
||||
|
||||
### Changed
|
||||
- Standardized header bar heights to 56px across all panels
|
||||
- Container grid now correctly parses Docker API JSON format (capitalized keys)
|
||||
- Status column displays clean uptime (stripped health indicators)
|
||||
- Status badges have consistent minimum width (90px)
|
||||
- Search bar styling improved (36px height, visible border, proper background)
|
||||
- Quick links now properly persist link type (iframe vs new tab)
|
||||
- Iframe switching now closes existing content before loading new link
|
||||
|
||||
### Fixed
|
||||
- Quick links form properly saves changes and refreshes panel
|
||||
- `ContainerState` type conflict resolved (removed duplicate enum)
|
||||
- Container data parsing handles null values safely
|
||||
|
||||
### Branding
|
||||
- Updated favicon and icons with Tatlock bucket logo
|
||||
- Updated manifest.json with Tatlock branding
|
||||
|
||||
## [0.3.2] - 2025-01-02
|
||||
|
||||
### Changed
|
||||
- API defaults now use LAN IPs for local development (no auth required)
|
||||
- Auth interceptor skips authentication when using LAN endpoints
|
||||
|
||||
## [0.3.0] - 2025-12-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# CLAUDE.md — tatlock-ui
|
||||
|
||||
Flutter/Dart frontend for the homelab — the dashboard at `home.schweitz.net`. Riverpod state,
|
||||
Material 3, Go-Router. Built as a **Flutter web** app, served as compiled static assets by nginx
|
||||
in the `tatlock-ui` container. `pubspec.yaml` version **1.7.1+1**, package name `tatlock_ui`,
|
||||
Dart SDK `^3.10.4`.
|
||||
|
||||
It is a **client**, not a service. It talks to core-api (:8083) and tatlock (:8000); it exposes
|
||||
no API of its own and has no `/openapi.json`.
|
||||
|
||||
## Read first
|
||||
|
||||
- **[PHILOSOPHY.md](PHILOSOPHY.md)** — the system vision and the architectural patterns all work
|
||||
should move toward. The previous AGENTS.md made this a mandatory pre-work read and that
|
||||
requirement is carried forward deliberately.
|
||||
- **[docs/UI_LAYOUT.md](docs/UI_LAYOUT.md)** — **read before touching the widget tree.** Check
|
||||
`lib/shared/components/` for an existing component before building a new one, and look at how a
|
||||
comparable feature already does it. Deviating from the established patterns is the main source
|
||||
of drift here.
|
||||
- `docs/` also holds `ARCHITECTURE.md`, `API_INTEGRATION.md`, `TESTING.md`, `THEMING.md`,
|
||||
`DATAGRID.md`, `DEPLOYMENT.md`.
|
||||
|
||||
## Ports and where it runs
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Container | `tatlock-ui`, `127.0.0.1:9999 -> 80` (nginx serving the web build) |
|
||||
| External | `home.schweitz.net` |
|
||||
| Backends | core-api `:8083`, tatlock `:8000` |
|
||||
|
||||
The old AGENTS.md said the app is "available at `http://tower:8092`". **That is stale** — the
|
||||
published port is 9999, verified against `docker ps` on 2026-08-09.
|
||||
|
||||
It also pointed at `portainer-core` for full-stack documentation. **`portainer-core` is
|
||||
deprecated** and must not be used as a source of infra facts; it was merged into
|
||||
`system-admin-toj/containers/`. The live inventory is `CONTAINERS.md` there.
|
||||
|
||||
The Gitea SSO-bypass trick is real and still works: `http://localhost:3002` reaches Gitea
|
||||
directly, verified returning `{"version":"1.27.1"}`. Useful for reading a sibling repo's raw
|
||||
files without going through Authentik.
|
||||
|
||||
## Layout
|
||||
|
||||
`lib/main.dart` → `lib/app.dart`; `lib/core/` (api, auth, config, error, providers, semantics,
|
||||
theme), `lib/features/<room>/` (control_room, front_hall, media_room, parlor, security,
|
||||
settings), `lib/routing/`, `lib/shared/` (components, layouts, theme, widgets). 132 Dart files
|
||||
under `lib/`, 24 test files.
|
||||
|
||||
## Establishing what is live — and the trap in it
|
||||
|
||||
There is no `sys.modules` here and nothing to `docker exec` into: the container holds compiled
|
||||
assets, not source. The Dart analogue is a transitive walk of `import`/`export`/`part`
|
||||
directives from `lib/main.dart`, resolving `package:tatlock_ui/…` to `lib/…`. Run 2026-08-09:
|
||||
124 of 132 files reachable, 8 not.
|
||||
|
||||
**Do not read that as a delete list. Five of the eight are the code that actually runs in
|
||||
production.** They are conditional-import targets:
|
||||
|
||||
```dart
|
||||
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart';
|
||||
```
|
||||
|
||||
A naive walk captures the *first* string and misses the branch. Since this app ships as Flutter
|
||||
**web**, the `_web.dart` half is the live one and the `_stub`/`_native` half is the dormant one —
|
||||
the exact inverse of what the reachability count suggests. The five: `api_client_web.dart`,
|
||||
`web_utils_web.dart`, `url_strategy_web.dart`, `url_state_web.dart`,
|
||||
`iframe_view_web.dart`. Find them all with `grep -rn "if (dart.library" lib/`.
|
||||
|
||||
That leaves three genuinely unreferenced files, and they are **not** all the same thing:
|
||||
|
||||
| File | Status |
|
||||
|---|---|
|
||||
| `lib/core/auth/permission_gate.dart` | no reference anywhere in `lib/` or `test/` |
|
||||
| `lib/core/semantics/semantic_widget.dart` | no reference anywhere in `lib/` or `test/` |
|
||||
| `lib/features/control_room/stacks/data/models/stack_model.dart` | **referenced only by its own test** |
|
||||
|
||||
The third is the interesting one: `stack_model_test.dart` imports and exercises it, so the suite
|
||||
is green and gives confidence about a model the app never uses. A passing test is not evidence a
|
||||
thing is wired in.
|
||||
|
||||
Before deleting any of the three, check whether it is intended groundwork rather than debris —
|
||||
`TODO_AUTH_REFACTOR.md` describes an unimplemented auth redesign, and `permission_gate.dart` sits
|
||||
squarely in that area. Neither that file nor `PLAN.md` mentions it by name, so its status is
|
||||
**undetermined**, not dead. Ask before removing.
|
||||
|
||||
## Tooling
|
||||
|
||||
`flutter` and `dart` resolve from `/snap/bin`, which **is** on the non-interactive `PATH` — so
|
||||
bare commands work here (unlike `pql`, which needs its absolute path).
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter test # 24 test files
|
||||
flutter analyze # static analysis; analysis_options.yaml at the repo root
|
||||
flutter build web --release
|
||||
```
|
||||
|
||||
**Always add tests for new code before committing** — happy path plus key edge cases, widget
|
||||
tests for widgets, unit tests for logic. Coverage should not decrease. Carried over from the
|
||||
previous AGENTS.md, which stated it as "no exceptions".
|
||||
|
||||
Note there is **no CI test gate**: `.gitea/workflows/build.yml` triggers only on `v*` tag push
|
||||
and goes straight to build and release. `flutter test` runs locally or not at all.
|
||||
|
||||
## Work tracking
|
||||
|
||||
Work lives in **pql**, not a markdown TODO. **This repo's vault is standalone** — its tickets and
|
||||
its internal decisions live here in `.pql/` and `governance/`, and travel with a clone, because
|
||||
`.pql/changelog/` is committed and replayed by the git hooks (workspace D-15). The databases are gitignored
|
||||
and rebuildable with `pql plan rebuild`.
|
||||
|
||||
`pql` is **not** on the non-interactive `PATH` — invoke it as `/home/jpmschweitzer/.local/bin/pql`.
|
||||
From inside this repo no `--vault` is needed: pql anchors at the nearest `.git/` ancestor, which
|
||||
is this repo.
|
||||
|
||||
```bash
|
||||
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
|
||||
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
|
||||
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
|
||||
```
|
||||
|
||||
Stack-level decisions that constrain this app live in the **workspace** vault and need the flag:
|
||||
|
||||
```bash
|
||||
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain tatlock-ui
|
||||
```
|
||||
|
||||
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
|
||||
to a workspace decision. Cite the id in the ticket body instead.
|
||||
|
||||
`PLAN.md` and `TODO_AUTH_REFACTOR.md` predate this convention. Treat them as research notes;
|
||||
new work goes in pql.
|
||||
|
||||
## Git
|
||||
|
||||
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch that is
|
||||
fast-forwarded and deleted.
|
||||
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
|
||||
- **Stage explicitly. Never `git add -A`** — denied by policy, and it sweeps in whatever else is
|
||||
dirty.
|
||||
- **Every version increment gets a tag** — `vX.Y.Z`, on the `major.minor.patch` part, not the
|
||||
build number.
|
||||
- Update `CHANGELOG.md` for every user-facing change.
|
||||
|
||||
**`.claude/settings.local.json` is currently tracked in git here.** Contents are benign — a
|
||||
four-entry permission allow list, no `env` block, no secrets — but it is machine-local state that
|
||||
should not be shared. There is now a `.gitignore` rule for it, and **that rule is inert**: git
|
||||
applies ignore rules only to untracked paths, so edits still show in `git status` and still get
|
||||
committed. It starts working only after `git rm --cached .claude/settings.local.json`, which is a
|
||||
history decision and was deliberately left out of normalization.
|
||||
|
||||
Consequence for checking: plain `git check-ignore` prints nothing for this path — it consults the
|
||||
index — which looks identical to "no rule exists". Use `--no-index` to test the rule itself.
|
||||
|
||||
## Releasing
|
||||
|
||||
1. Bump `version` in `pubspec.yaml` (the `major.minor.patch` part).
|
||||
2. Move `[Unreleased]` entries into a dated section in `CHANGELOG.md`.
|
||||
3. Stage by name, commit, tag `vX.Y.Z`, `git push origin main --tags`.
|
||||
4. Gitea CI builds the web app in Docker, pushes `:latest` and `:vX.Y.Z`; Watchtower deploys.
|
||||
5. Verify at `home.schweitz.net`, or `curl -I http://localhost:9999`.
|
||||
|
||||
**Rollback:** in Portainer, point the image tag at the previous version.
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Stage 1: Build Flutter web application
|
||||
FROM ghcr.io/cirruslabs/flutter:stable AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# VERSION arg busts cache when version changes in pubspec.yaml
|
||||
# Extract version: docker build --build-arg VERSION=$(grep '^version:' pubspec.yaml | cut -d' ' -f2) .
|
||||
ARG VERSION=0.0.0
|
||||
RUN echo "Building version: $VERSION"
|
||||
|
||||
# Copy dependency files first for better caching
|
||||
COPY pubspec.yaml pubspec.lock* ./
|
||||
|
||||
# Get dependencies
|
||||
RUN flutter pub get
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Clean any cached build artifacts to ensure fresh build
|
||||
RUN flutter clean && flutter pub get
|
||||
|
||||
# Generate code with build_runner (after clean for fresh generation)
|
||||
RUN dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
# Generate health.json with version info
|
||||
RUN dart run tool/generate_health_json.dart
|
||||
|
||||
# Build for web release with production configuration
|
||||
# These URLs enable authentication (requiresAuth = true when URL contains schweitz.net)
|
||||
RUN flutter build web --release \
|
||||
--dart-define=CORE_API_URL=https://api.schweitz.net \
|
||||
--dart-define=TATLOCK_API_URL=https://tatlock.schweitz.net
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built web app to nginx html directory
|
||||
COPY --from=builder /app/build/web /usr/share/nginx/html
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:80/ || exit 1
|
||||
|
||||
# Run nginx in foreground
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,92 @@
|
||||
# tatlock-ui — the repo's command surface (D-27).
|
||||
#
|
||||
# Flutter rather than Python, so there is no venv and no PYTHON here. The
|
||||
# reason the targets still exist under these names is the point of D-27: an
|
||||
# agent or a person can run `make test` in any repo in this workspace without
|
||||
# first working out which stack it is.
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@grep -hE '^[a-z][a-z0-9_-]*:.*?## ' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: setup
|
||||
setup: ## Fetch dependencies and generate code (run this after a fresh clone)
|
||||
flutter pub get
|
||||
$(MAKE) generate
|
||||
@ci/check_codegen.sh
|
||||
|
||||
.PHONY: generate
|
||||
generate: ## Regenerate freezed/json_serializable/riverpod sources
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
.PHONY: check-codegen
|
||||
check-codegen: ## Prove every part directive has a generated file on disk
|
||||
@ci/check_codegen.sh
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run the widget and unit tests
|
||||
@ci/check_codegen.sh \
|
||||
|| { echo "FAIL — generated sources missing or stale; run: make setup"; exit 69; }
|
||||
flutter test
|
||||
|
||||
# Why the guard above: *.freezed.dart and lib/**/*.g.dart are gitignored, so a
|
||||
# fresh clone has none of them and most of the suite fails to compile rather
|
||||
# than to assert. On 2026-08-09 that read as "26 passed, 17 failed" — which
|
||||
# looks like broken tests and is actually a missing build step. After
|
||||
# generating, the same suite is 452 passed. A test run that cannot compile
|
||||
# should say so in those words.
|
||||
#
|
||||
# `setup` and `test` both call ci/check_codegen.sh rather than one calling
|
||||
# the other's target, because `setup`'s job is "make the tree usable" (fails
|
||||
# loud if codegen silently produced less than the tree needs) and `test`'s
|
||||
# job is "is the tree usable right now" (fails loud if nobody ran setup at
|
||||
# all, or ran it before a source file changed). Same check, two different
|
||||
# questions, so a shared script rather than a shared Make target — a Make
|
||||
# target can only be reused by depending on it, which would make `test`
|
||||
# imply `flutter pub get` and `build_runner`, both slow, every run.
|
||||
#
|
||||
# The check walks every `part '<name>.g.dart'`/`part '<name>.freezed.dart'`
|
||||
# directive under lib/ and confirms the named sibling file exists — not one
|
||||
# sentinel file (the previous guard checked only
|
||||
# user_preferences.freezed.dart, which would have missed 44 of the 45
|
||||
# directives that exist today). See ci/check_codegen.sh for why this is
|
||||
# preferred over `flutter analyze`: cheaper, and it targets exactly the
|
||||
# generated/ungenerated distinction rather than static analysis in general.
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Static analysis (analysis_options.yaml at the repo root)
|
||||
flutter analyze
|
||||
|
||||
.PHONY: build
|
||||
build: ## Release build for the web target
|
||||
flutter build web --release
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Remove build artefacts and the pub cache for this project
|
||||
flutter clean
|
||||
|
||||
# git hands a hook a non-login shell, which never sees ~/.local/bin — where
|
||||
# gitleaks lands. Without this the scan reports "not installed" on every push,
|
||||
# which is a check that fails open (D-24).
|
||||
export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH)
|
||||
|
||||
.PHONY: secrets
|
||||
secrets: ## Scan the commits about to be pushed for credentials
|
||||
@ci/secrets.sh
|
||||
|
||||
# The call surface is identical in every repo; what it runs is not.
|
||||
#
|
||||
# `secrets` runs first, deliberately: it is the only failure here that cannot be
|
||||
# undone by fixing it afterwards. A failed lint costs another commit; a pushed
|
||||
# credential is cached and indexed whether or not it is later deleted.
|
||||
#
|
||||
# Some of these fail today, and are left wired anyway. The state was measured
|
||||
# once and written down in T-56 rather than being worked around here — a gate
|
||||
# quietly narrowed to what already passes is a gate that reports success for
|
||||
# doing nothing, which is the failure this workspace keeps rediscovering.
|
||||
.PHONY: pre-push
|
||||
pre-push: secrets lint ## Everything the pre-push hook runs
|
||||
@echo " -- not gated here yet: test (T-56)"
|
||||
+1
-1
@@ -109,7 +109,7 @@ The butler (backend) runs the household. The UI opens the door.
|
||||
|
||||
**Related Documents**:
|
||||
- **README.md**: Project setup and operational details
|
||||
- **AGENTS.md**: LLM agent development guidelines
|
||||
- **CLAUDE.md**: LLM agent development guidelines
|
||||
- **PLAN.md**: Implementation roadmap and phases
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Auth Flow Refactor: Invisible Token Exchange
|
||||
|
||||
> Research note for future implementation
|
||||
|
||||
## Problem
|
||||
|
||||
The `/callback?code=...` URL is visible in the browser during token refresh. This happens every time tokens need refreshing, not just on initial login. The current implementation appears to re-run the full OIDC redirect flow (`prompt=none`) instead of using the refresh_token.
|
||||
|
||||
## Current Behavior
|
||||
|
||||
```
|
||||
Token expires → Redirect to Authentik (prompt=none) →
|
||||
Redirect to /callback?code=xxx → Exchange code → Continue
|
||||
```
|
||||
|
||||
User sees URL flicker to `/callback` repeatedly.
|
||||
|
||||
## Desired Behavior
|
||||
|
||||
```
|
||||
Token expires → Show overlay (lock/lightning icon) →
|
||||
XHR refresh request → Hide overlay → Continue
|
||||
```
|
||||
|
||||
No URL changes. No redirects. Just a brief visual indicator.
|
||||
|
||||
## Key Insight
|
||||
|
||||
**Only the initial authorization MUST redirect** (user needs to see Authentik login UI).
|
||||
|
||||
Everything else can be XHR:
|
||||
|
||||
| Operation | Current | Should Be |
|
||||
|-----------|---------|-----------|
|
||||
| Initial login | Redirect | Redirect (unavoidable) |
|
||||
| Token exchange (code → tokens) | Redirect to /callback | XHR POST |
|
||||
| Token refresh | Full OIDC with prompt=none | XHR POST with refresh_token |
|
||||
| Session expired | Redirect | Redirect (unavoidable) |
|
||||
|
||||
## Token Refresh via XHR
|
||||
|
||||
```dart
|
||||
final response = await dio.post(
|
||||
'https://authentik.schweitz.net/application/o/token/',
|
||||
data: {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': storedRefreshToken,
|
||||
'client_id': clientId,
|
||||
},
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
),
|
||||
);
|
||||
// Returns new access_token, refresh_token, expires_in
|
||||
```
|
||||
|
||||
## Potential Blocker: CORS
|
||||
|
||||
Authentik's token endpoint may block browser XHR. Solutions:
|
||||
|
||||
1. **Configure Authentik CORS** - Allow `home.schweitz.net` origin
|
||||
2. **Proxy through core-api** (recommended)
|
||||
- Flutter → `POST /auth/refresh` → core-api → Authentik
|
||||
- Keeps client_secret server-side
|
||||
- No CORS issues
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. [ ] Verify Authentik is issuing refresh_tokens (check token response)
|
||||
2. [ ] Check if refresh_token is being stored (SharedPreferences)
|
||||
3. [ ] Test XHR to token endpoint (check CORS)
|
||||
4. [ ] If CORS blocked, add `/auth/refresh` endpoint to core-api
|
||||
5. [ ] Refactor `AuthProvider` to use XHR refresh instead of full OIDC flow
|
||||
6. [ ] Add refresh overlay UI (lock icon + brief animation)
|
||||
7. [ ] Remove `prompt=none` redirect logic for refresh cases
|
||||
|
||||
## Files to Investigate
|
||||
|
||||
- `lib/core/auth/auth_provider.dart` - Main auth state management
|
||||
- `lib/core/auth/oidc_service_web.dart` - OIDC implementation
|
||||
- `lib/core/api/api_interceptors.dart` - Token refresh trigger point
|
||||
|
||||
## References
|
||||
|
||||
- CHANGELOG entries v1.1.3-v1.1.6 document the current auth architecture
|
||||
- AuthController runs in `main()` before `runApp()` (v1.1.6 pattern)
|
||||
@@ -9,6 +9,12 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
errors:
|
||||
# Freezed uses @JsonKey on constructor parameters which triggers this warning
|
||||
# but is the correct pattern for freezed classes
|
||||
invalid_annotation_target: ignore
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
|
||||
@@ -24,10 +24,13 @@ android {
|
||||
applicationId = "net.schweitz.tatlock_ui"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
minSdk = 23 // Required for AppAuth
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
// flutter_appauth redirect scheme for OIDC callbacks
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = "net.schweitz.tatlock"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 396 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 977 KiB |
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prove that generated sources exist for every part directive that requires
|
||||
# one, after `make generate` has run. Lives here rather than inline in the
|
||||
# Makefile so it can be read, run by hand (`make check-codegen`), and changed
|
||||
# under review — same reasoning as ci/secrets.sh (D-27).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# *.freezed.dart and lib/**/*.g.dart are gitignored, so a fresh or stale
|
||||
# checkout can silently have some but not all of the files a `part`
|
||||
# directive names. `dart run build_runner build` exits 0 whether it produced
|
||||
# everything the tree needs or almost nothing — exit code is not evidence
|
||||
# (T-47). What IS evidence: every `part '<name>';` directive in lib/ names a
|
||||
# sibling file, and that file either exists or it doesn't. This walks every
|
||||
# directive and checks its target directly, rather than trusting a single
|
||||
# sentinel file (the old `test` guard checked one file,
|
||||
# user_preferences.freezed.dart, and would have missed 44 other gaps).
|
||||
#
|
||||
# On 2026-08-09 this exact condition was 4 generated files present where 46
|
||||
# were needed. `flutter analyze` would also catch it, but slower and later —
|
||||
# this check is the cheapest thing that proves the same fact.
|
||||
|
||||
missing=0
|
||||
checked=0
|
||||
|
||||
while IFS=: read -r file part_line; do
|
||||
# grep -H prefixes exactly one "file:" — no line numbers, so a colon
|
||||
# inside the match (there is none here, but be safe) can't split wrong.
|
||||
# part_line looks like: part 'auth_state.freezed.dart';
|
||||
target=$(printf '%s' "$part_line" | sed -E "s/^part '([^']+)';.*/\1/")
|
||||
dir=$(dirname "$file")
|
||||
checked=$((checked + 1))
|
||||
if [ ! -f "$dir/$target" ]; then
|
||||
echo "MISSING generated file: $dir/$target (required by 'part' directive in $file)" >&2
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
done < <(grep -rH "^part '" lib --include='*.dart')
|
||||
|
||||
if [ "$checked" -eq 0 ]; then
|
||||
echo "FAIL check-codegen — found zero 'part' directives under lib/; the check itself is broken, not the tree." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$missing" -gt 0 ]; then
|
||||
echo "FAIL check-codegen — $missing of $checked generated files are missing. Run: make generate" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "check-codegen — $checked/$checked generated files present."
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Secret scan over the commits about to be pushed.
|
||||
#
|
||||
# Lives here rather than inside .githooks/pre-push so it can be read, run by
|
||||
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
|
||||
# not a home for logic. Identical in every repo in this workspace (D-27).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
|
||||
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
|
||||
# Without this the scan reports "not installed" on every push.
|
||||
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
|
||||
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Scan the outgoing range, not full history. History here carries findings
|
||||
# that are settled — test fixtures and vendored third-party code — and a gate
|
||||
# that fails on something unfixable gets bypassed within a week. What matters
|
||||
# is what is about to leave this machine.
|
||||
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
|
||||
range="$upstream..HEAD"
|
||||
elif git rev-parse --verify --quiet origin/main >/dev/null; then
|
||||
range="origin/main..HEAD"
|
||||
else
|
||||
range=""
|
||||
fi
|
||||
|
||||
if [ -z "$range" ]; then
|
||||
gitleaks dir . --redact --no-banner --exit-code 1 || {
|
||||
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
|
||||
|
||||
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
|
||||
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
|
||||
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
|
||||
echo " then remove and rotate it, or suppress deliberately:" >&2
|
||||
echo " inline '# gitleaks:allow <reason>'" >&2
|
||||
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
|
||||
exit 1
|
||||
}
|
||||
echo " ok secrets"
|
||||
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
+385
-10
@@ -20,6 +20,7 @@ lib/
|
||||
│ ├── auth/ # Authentik OIDC integration
|
||||
│ ├── config/ # Environment configuration
|
||||
│ ├── error/ # Error types and handling
|
||||
│ ├── semantics/ # Semantic IDs for automation
|
||||
│ └── theme/ # Material 3 theming
|
||||
├── routing/ # go_router configuration
|
||||
├── shared/ # Reusable components
|
||||
@@ -27,14 +28,20 @@ lib/
|
||||
│ └── layouts/ # App scaffold, navigation
|
||||
├── features/ # Feature modules (rooms)
|
||||
│ ├── front_hall/ # Dashboard - estate overview
|
||||
│ │ └── router.dart # Room registration
|
||||
│ ├── control_room/ # Infrastructure
|
||||
│ │ ├── router.dart # Room registration
|
||||
│ │ ├── containers/ # Container management
|
||||
│ │ ├── stacks/ # Stack management
|
||||
│ │ ├── networks/ # Network management
|
||||
│ │ └── volumes/ # Volume management
|
||||
│ ├── parlor/ # Housekeeping - home automation
|
||||
│ ├── library/ # Knowledge management (future)
|
||||
│ └── study/ # Secretarial tasks (future)
|
||||
│ │ └── npm/ # Proxy hosts management
|
||||
│ ├── security/ # User & access management
|
||||
│ │ ├── router.dart # Room registration
|
||||
│ │ ├── users/ # User management
|
||||
│ │ └── groups/ # Group management
|
||||
│ ├── parlor/ # AI chat & automation hub
|
||||
│ │ └── router.dart # Room registration
|
||||
│ ├── media_room/ # Media management (future)
|
||||
│ │ └── router.dart # Room registration
|
||||
│ └── settings/ # User preferences
|
||||
└── chat/ # Tatlock chat - omnipresent, NOT a room
|
||||
```
|
||||
|
||||
@@ -44,13 +51,16 @@ lib/
|
||||
|---------|-----------|---------|
|
||||
| Front Hall | `features/front_hall/` | Dashboard, overview, quick access |
|
||||
| Control Room | `features/control_room/` | Infrastructure management |
|
||||
| Parlor | `features/parlor/` | Home automation |
|
||||
| Library | `features/library/` | Knowledge, docs, bookmarks |
|
||||
| Study | `features/study/` | Email, calendar (hidden for now) |
|
||||
| Security | `features/security/` | User & access management |
|
||||
| Parlor | `features/parlor/` | AI chat & automation hub |
|
||||
| Media Room | `features/media_room/` | Media management (future) |
|
||||
| *(non-room)* | `features/settings/` | User preferences |
|
||||
| *(omnipresent)* | `chat/` | Tatlock assistant dock |
|
||||
|
||||
Note: `chat/` lives at the top level of `lib/` (not under `features/`) because it's not a navigable room - it's an omnipresent dock injected at the layout level.
|
||||
|
||||
Each room has a `router.dart` file that registers the room with the central registry. See [Room Registry Pattern](#room-registry-pattern) for details.
|
||||
|
||||
## Feature Structure
|
||||
|
||||
Each feature follows a three-layer architecture:
|
||||
@@ -142,7 +152,7 @@ part 'container_model.freezed.dart';
|
||||
part 'container_model.g.dart';
|
||||
|
||||
@freezed
|
||||
class ContainerModel with _$ContainerModel {
|
||||
sealed class ContainerModel with _$ContainerModel {
|
||||
const factory ContainerModel({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -197,6 +207,139 @@ class ContainerRepositoryImpl implements ContainerRepository {
|
||||
}
|
||||
```
|
||||
|
||||
## Data Model Patterns
|
||||
|
||||
Models handle conversion between API JSON and domain entities. The pattern depends on whether the feature is **read-only** or **CRUD**.
|
||||
|
||||
### Read-Only Models
|
||||
|
||||
For data fetched from external systems (Docker, NPM, Portainer) where Flutter doesn't create/update records:
|
||||
|
||||
```dart
|
||||
@freezed
|
||||
sealed class ContainerModel with _$ContainerModel {
|
||||
const factory ContainerModel({
|
||||
required String id,
|
||||
required String name,
|
||||
required String status,
|
||||
}) = _ContainerModel;
|
||||
|
||||
const ContainerModel._();
|
||||
|
||||
factory ContainerModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ContainerModelFromJson(json);
|
||||
|
||||
/// Converts API response to domain entity.
|
||||
Container toEntity() => Container(
|
||||
id: id,
|
||||
name: name,
|
||||
status: ContainerStatus.values.byName(status),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Only `toEntity()` is needed** - no `fromEntity()` or `toJson()` required.
|
||||
|
||||
### CRUD Models (Bidirectional)
|
||||
|
||||
For data that Flutter creates, updates, and deletes:
|
||||
|
||||
```dart
|
||||
@freezed
|
||||
sealed class QuickLinkModel with _$QuickLinkModel {
|
||||
const factory QuickLinkModel({
|
||||
@Default(0) int id,
|
||||
required String title,
|
||||
required String url,
|
||||
String? icon,
|
||||
String? category,
|
||||
@Default(0) int position,
|
||||
@JsonKey(name: 'is_visible') @Default(true) bool isVisible,
|
||||
}) = _QuickLinkModel;
|
||||
|
||||
const QuickLinkModel._();
|
||||
|
||||
factory QuickLinkModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$QuickLinkModelFromJson(json);
|
||||
|
||||
/// Converts API response to domain entity.
|
||||
QuickLink toEntity() => QuickLink(
|
||||
id: id.toString(),
|
||||
name: title,
|
||||
url: url,
|
||||
iconName: icon ?? 'link',
|
||||
category: category,
|
||||
sortOrder: position,
|
||||
isActive: isVisible,
|
||||
);
|
||||
|
||||
/// Creates model from domain entity for API requests.
|
||||
factory QuickLinkModel.fromEntity(QuickLink entity) => QuickLinkModel(
|
||||
id: int.tryParse(entity.id) ?? 0,
|
||||
title: entity.name,
|
||||
url: entity.url,
|
||||
icon: entity.iconName,
|
||||
category: entity.category,
|
||||
position: entity.sortOrder,
|
||||
isVisible: entity.isActive,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Critical rules for CRUD models:**
|
||||
|
||||
1. **Always use generated `toJson()`** - Never write custom JSON methods that selectively include fields. The generated `toJson()` from freezed/json_serializable always includes ALL fields, which is the correct behavior.
|
||||
|
||||
2. **Never create custom `toCreateJson()` or similar** - This leads to bugs where fields are silently dropped.
|
||||
|
||||
3. **Use `fromEntity()` factory** - Maps domain entity fields to API field names.
|
||||
|
||||
### Form Update Pattern
|
||||
|
||||
When updating existing entities in forms, **always use `copyWith()`** to preserve existing data:
|
||||
|
||||
```dart
|
||||
// ✅ Correct - preserves all existing fields
|
||||
final updatedLink = existingLink.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
url: _urlController.text.trim(),
|
||||
category: category.isEmpty ? null : category,
|
||||
);
|
||||
await actions.update(updatedLink);
|
||||
|
||||
// ❌ Wrong - loses existing data not in form
|
||||
final newLink = QuickLink(
|
||||
id: existingLink.id,
|
||||
name: _nameController.text.trim(),
|
||||
url: _urlController.text.trim(),
|
||||
// Missing: sortOrder, other fields...
|
||||
);
|
||||
```
|
||||
|
||||
### Datasource Usage
|
||||
|
||||
```dart
|
||||
// Create - convert entity to model, use toJson()
|
||||
Future<QuickLink> createQuickLink(QuickLink link) async {
|
||||
final model = QuickLinkModel.fromEntity(link);
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
data: model.toJson(), // Always use generated toJson()
|
||||
);
|
||||
return QuickLinkModel.fromJson(response.data!).toEntity();
|
||||
}
|
||||
|
||||
// Update - same pattern
|
||||
Future<QuickLink> updateQuickLink(QuickLink link) async {
|
||||
final model = QuickLinkModel.fromEntity(link);
|
||||
final response = await _dio.put<Map<String, dynamic>>(
|
||||
'$_basePath/${link.id}',
|
||||
data: model.toJson(), // Always use generated toJson()
|
||||
);
|
||||
return QuickLinkModel.fromJson(response.data!).toEntity();
|
||||
}
|
||||
```
|
||||
|
||||
### Presentation Layer (Flutter + Riverpod)
|
||||
|
||||
The presentation layer contains UI code and state management.
|
||||
@@ -287,6 +430,41 @@ ContainerRepository containerRepository(Ref ref) {
|
||||
}
|
||||
```
|
||||
|
||||
### Persistent Providers
|
||||
|
||||
By default, `@riverpod` generates providers with `isAutoDispose: true`, meaning they dispose when no longer watched. This causes issues for:
|
||||
|
||||
- **API clients** with interceptors that store a `Ref`
|
||||
- **App-level state** like theme, auth, config
|
||||
- **Providers with listeners** to other providers
|
||||
|
||||
Use `@persistentRiverpod` from `core/providers/annotations.dart` for these cases:
|
||||
|
||||
```dart
|
||||
import 'package:tatlock_ui/core/providers/annotations.dart';
|
||||
|
||||
// ✅ Correct - persists for app lifetime
|
||||
@persistentRiverpod
|
||||
Dio coreApiClient(Ref ref) { ... }
|
||||
|
||||
@persistentRiverpod
|
||||
class ThemeNotifier extends _$ThemeNotifier { ... }
|
||||
|
||||
// ❌ Wrong - auto-dispose can invalidate stored Ref
|
||||
@riverpod
|
||||
Dio coreApiClient(Ref ref) { ... }
|
||||
```
|
||||
|
||||
**When to use `@persistentRiverpod`:**
|
||||
|
||||
| Use Case | Annotation | Example |
|
||||
|----------|------------|---------|
|
||||
| API clients with interceptors | `@persistentRiverpod` | `coreApiClient`, `tatlockApiClient` |
|
||||
| Auth state provider | `@persistentRiverpod` | `AuthNotifier` |
|
||||
| Theme/config providers | `@persistentRiverpod` | `ThemeNotifier` |
|
||||
| Feature data providers | `@riverpod` (default) | `ContainersNotifier` |
|
||||
| UI state providers | `@riverpod` (default) | `SearchFilterNotifier` |
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
| Type | Convention | Example |
|
||||
@@ -331,6 +509,34 @@ Generated files:
|
||||
- `*.freezed.dart` - Immutable classes
|
||||
- `*.g.dart` - JSON serialization, Riverpod providers
|
||||
|
||||
### Freezed 3.x: Required `sealed class`
|
||||
|
||||
**Freezed 3.x requires the `sealed` keyword** on all classes with generated mixins. Without it, the generated code will fail to compile with errors about missing concrete implementations.
|
||||
|
||||
```dart
|
||||
// ✅ Correct - Freezed 3.x
|
||||
@freezed
|
||||
sealed class UserModel with _$UserModel {
|
||||
const factory UserModel({
|
||||
required String id,
|
||||
required String name,
|
||||
}) = _UserModel;
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$UserModelFromJson(json);
|
||||
}
|
||||
|
||||
// ❌ Wrong - will fail to compile
|
||||
@freezed
|
||||
class UserModel with _$UserModel { // Missing `sealed`
|
||||
const factory UserModel({...}) = _UserModel;
|
||||
}
|
||||
```
|
||||
|
||||
The `sealed` keyword was introduced in Dart 3.0 and allows the generated mixin `_$UserModel` to have abstract members that are implemented by the private `_UserModel` class.
|
||||
|
||||
**Always use `sealed class` with `@freezed`** - this applies to all models, entities, and state classes using Freezed.
|
||||
|
||||
## Import Rules
|
||||
|
||||
1. Never import from `data/` in `domain/`
|
||||
@@ -348,3 +554,172 @@ import 'package:tatlock_ui/features/containers/domain/entities/container.dart';
|
||||
// Bad - importing model in presentation
|
||||
import '../data/models/container_model.dart'; // Don't do this
|
||||
```
|
||||
|
||||
## Semantic Identifiers for Automation
|
||||
|
||||
All interactive widgets should have semantic identifiers for UI automation. This enables reliable testing with Puppeteer, Appium, and other automation tools.
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```dart
|
||||
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
||||
|
||||
// Wrap interactive widgets with Semantics
|
||||
Semantics(
|
||||
identifier: DataGridSemantics.row(item.id),
|
||||
label: 'Select ${item.name}',
|
||||
child: MyRowWidget(item: item),
|
||||
)
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
1. **Central ID Registry** - All IDs defined in `lib/core/semantics/semantic_ids.dart`
|
||||
2. **Naming Convention** - `{area}_{component}_{identifier}` (e.g., `dataGrid_row_abc123`)
|
||||
3. **Web Enabled** - Semantics tree exposed via `SemanticsBinding.instance.ensureSemantics()` in `main.dart`
|
||||
|
||||
For complete documentation on semantic patterns, automation queries, and best practices, see **[TESTING.md](./TESTING.md)**.
|
||||
|
||||
## Room Registry Pattern
|
||||
|
||||
The application uses a **decentralized room registry** pattern for navigation. Each feature/room registers itself with the central registry, providing:
|
||||
|
||||
- **Decoupled navigation** - Rooms define their own routes, icons, and metadata
|
||||
- **Permission-based filtering** - Rooms can specify required permissions
|
||||
- **Dynamic UI** - Settings dropdowns and tab bars build from registry
|
||||
- **Single source of truth** - All room metadata in one place per room
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
lib/routing/room_registry.dart # Central registry class
|
||||
lib/features/{room}/router.dart # Per-room registration
|
||||
```
|
||||
|
||||
### Room Definition
|
||||
|
||||
Each room's `router.dart` exports a `RoomDefinition` and register function:
|
||||
|
||||
```dart
|
||||
// lib/features/control_room/router.dart
|
||||
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||
|
||||
/// Room definition with all metadata.
|
||||
final controlRoomRoom = RoomDefinition(
|
||||
id: 'control-room', // Preference value, URL segment
|
||||
label: 'Control Room', // Display name
|
||||
icon: Icons.dns_outlined, // Unselected icon
|
||||
selectedIcon: Icons.dns, // Selected icon
|
||||
defaultRoute: '/control-room/containers', // Landing route
|
||||
routes: controlRoomRoutes, // Function returning List<RouteBase>
|
||||
requiredPermissions: [], // Empty = accessible to all
|
||||
);
|
||||
|
||||
/// Register with the central registry.
|
||||
void registerControlRoom() {
|
||||
roomRegistry.register(controlRoomRoom);
|
||||
}
|
||||
|
||||
/// Routes for go_router.
|
||||
List<RouteBase> controlRoomRoutes() {
|
||||
return [
|
||||
GoRoute(path: '/control-room', ...),
|
||||
// Sub-routes...
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Registration Order
|
||||
|
||||
Rooms are registered in `app_router.dart` in display order:
|
||||
|
||||
```dart
|
||||
void _initializeRoomRegistry() {
|
||||
if (roomRegistry.all.isNotEmpty) return; // Skip if initialized
|
||||
|
||||
// Registration order = tab order
|
||||
registerFrontHall();
|
||||
registerControlRoom();
|
||||
registerSecurity();
|
||||
registerParlor();
|
||||
registerMediaRoom();
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Registry
|
||||
|
||||
**Navigation tabs** (`top_header_bar.dart`):
|
||||
```dart
|
||||
List<RoomDefinition> get _rooms => roomRegistry.all;
|
||||
|
||||
// Build tab for each room
|
||||
for (final room in _rooms) {
|
||||
IconButton(
|
||||
icon: Icon(isSelected ? room.selectedIcon : room.icon),
|
||||
onPressed: () => onRoomSelected(index),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Settings dropdown**:
|
||||
```dart
|
||||
DropdownButton<String>(
|
||||
items: roomRegistry.all
|
||||
.map((room) => DropdownMenuItem(
|
||||
value: room.id,
|
||||
child: Text(room.label),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
```
|
||||
|
||||
**Router** - All routes from registry:
|
||||
```dart
|
||||
ShellRoute(
|
||||
routes: [
|
||||
...roomRegistry.allRoutes(),
|
||||
// Plus non-room routes like /settings
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
**Route matching**:
|
||||
```dart
|
||||
int _selectedIndex(BuildContext context) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
return roomRegistry.indexOfRoute(location);
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Filtering
|
||||
|
||||
Rooms can specify required permissions:
|
||||
|
||||
```dart
|
||||
final adminRoom = RoomDefinition(
|
||||
id: 'admin',
|
||||
requiredPermissions: ['admin:access'],
|
||||
// ...
|
||||
);
|
||||
|
||||
// Filter by user permissions
|
||||
final accessibleRooms = roomRegistry.accessibleTo(userPermissions);
|
||||
```
|
||||
|
||||
### Adding a New Room
|
||||
|
||||
1. Create feature folder: `lib/features/{room_name}/`
|
||||
2. Create `router.dart` with `RoomDefinition` and register function
|
||||
3. Create placeholder page in `presentation/pages/{room_name}_page.dart`
|
||||
4. Add `register{RoomName}()` call to `_initializeRoomRegistry()` in `app_router.dart`
|
||||
5. Import the router in `app_router.dart`
|
||||
|
||||
### Current Rooms
|
||||
|
||||
| Room | ID | Default Route |
|
||||
|------|-----|---------------|
|
||||
| Front Hall | `front-hall` | `/front-hall` |
|
||||
| Control Room | `control-room` | `/control-room/containers` |
|
||||
| Security | `security` | `/security/users` |
|
||||
| Parlor | `parlor` | `/parlor` |
|
||||
| Media Room | `media-room` | `/media-room` |
|
||||
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
# Testing & Automation Guide
|
||||
|
||||
This document covers automated testing patterns for Tatlock UI, focusing on semantic identifiers that enable reliable UI automation.
|
||||
|
||||
## Overview
|
||||
|
||||
Tatlock UI uses Flutter's **Semantics tree** to expose stable identifiers for automated testing. These identifiers are accessible to:
|
||||
|
||||
- **Puppeteer** (via Chrome DevTools accessibility API)
|
||||
- **Appium** (via accessibility labels)
|
||||
- **WebDriver** (via ARIA attributes)
|
||||
- **Flutter integration tests**
|
||||
|
||||
The semantics system is enabled on web in `main.dart`:
|
||||
|
||||
```dart
|
||||
if (kIsWeb) {
|
||||
SemanticsBinding.instance.ensureSemantics();
|
||||
}
|
||||
```
|
||||
|
||||
## Semantic Identifiers
|
||||
|
||||
All semantic IDs are centralized in `lib/core/semantics/semantic_ids.dart`. This provides:
|
||||
|
||||
1. **Stable selectors** - IDs don't change with UI refactoring
|
||||
2. **Type safety** - Compile-time verification of ID usage
|
||||
3. **Discoverability** - Single source of truth for automation targets
|
||||
|
||||
### Available ID Classes
|
||||
|
||||
| Class | Purpose | Example IDs |
|
||||
|-------|---------|-------------|
|
||||
| `ProfileSemantics` | User profile dropdown | `profile_button`, `profile_menu_settings` |
|
||||
| `RoomTabSemantics` | Main navigation tabs | `roomTab_frontHall`, `roomTab_controlRoom` |
|
||||
| `NavSemantics` | Side navigation panel | `nav_panel`, `nav_item_{id}` |
|
||||
| `DataGridSemantics` | Data tables | `dataGrid_row_{id}`, `dataGrid_search` |
|
||||
| `DialogSemantics` | Modal dialogs | `dialog_confirm`, `dialog_cancel` |
|
||||
| `SettingsSemantics` | Settings page | `settings_theme`, `settings_defaultRoom` |
|
||||
| `StateSemantics` | Loading/error states | `state_auth_loading`, `snackbar_{type}` |
|
||||
|
||||
### ID Naming Convention
|
||||
|
||||
```
|
||||
{area}_{component}_{identifier}
|
||||
```
|
||||
|
||||
- **area**: Feature or section (e.g., `profile`, `nav`, `dataGrid`)
|
||||
- **component**: Widget type (e.g., `menu`, `button`, `row`)
|
||||
- **identifier**: Specific item (e.g., `light`, `settings`, `selectAll`)
|
||||
|
||||
Examples:
|
||||
- `profile_menu_theme_dark` - Dark theme option in profile menu
|
||||
- `dataGrid_row_abc123` - Row with ID "abc123" in data grid
|
||||
- `nav_item_containers` - Containers nav item
|
||||
|
||||
## Adding Semantics to Widgets
|
||||
|
||||
### Method 1: Direct Semantics Widget
|
||||
|
||||
Use Flutter's `Semantics` widget with the `identifier` property:
|
||||
|
||||
```dart
|
||||
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
||||
|
||||
Semantics(
|
||||
identifier: ProfileSemantics.button,
|
||||
label: 'Open profile menu',
|
||||
button: true,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.person),
|
||||
onPressed: () => ...,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Method 2: SemanticWidget Wrapper
|
||||
|
||||
Use the convenience wrapper from `lib/core/semantics/semantic_widget.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
||||
import 'package:tatlock_ui/core/semantics/semantic_widget.dart';
|
||||
|
||||
SemanticWidget(
|
||||
id: DataGridSemantics.search,
|
||||
label: 'Search data grid',
|
||||
textField: true,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(hintText: 'Search...'),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Method 3: Extension Method
|
||||
|
||||
Use the `withSemantics` extension for inline wrapping:
|
||||
|
||||
```dart
|
||||
TextField(
|
||||
decoration: InputDecoration(hintText: 'Search...'),
|
||||
).withSemantics(
|
||||
id: DataGridSemantics.search,
|
||||
label: 'Search data grid',
|
||||
)
|
||||
```
|
||||
|
||||
### Dynamic IDs
|
||||
|
||||
For lists and grids, use the generator methods:
|
||||
|
||||
```dart
|
||||
// Row in a data grid
|
||||
Semantics(
|
||||
identifier: DataGridSemantics.row(item.id), // "dataGrid_row_abc123"
|
||||
child: DataGridRow(item: item),
|
||||
)
|
||||
|
||||
// Navigation item
|
||||
Semantics(
|
||||
identifier: NavSemantics.item(route.id), // "nav_item_containers"
|
||||
child: NavItem(route: route),
|
||||
)
|
||||
|
||||
// Bulk action button
|
||||
Semantics(
|
||||
identifier: DataGridSemantics.bulkAction('delete'), // "dataGrid_bulk_delete"
|
||||
child: IconButton(icon: Icon(Icons.delete), ...),
|
||||
)
|
||||
```
|
||||
|
||||
## Querying from Puppeteer
|
||||
|
||||
Puppeteer can query semantic identifiers via Chrome's accessibility tree:
|
||||
|
||||
```javascript
|
||||
// Connect to Chrome with DevTools protocol
|
||||
const browser = await puppeteer.connect({
|
||||
browserURL: 'http://localhost:9222'
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Get accessibility snapshot
|
||||
const snapshot = await page.accessibility.snapshot({ interestingOnly: false });
|
||||
|
||||
// Find element by semantic identifier
|
||||
function findBySemanticId(node, id) {
|
||||
if (node.name === id || node.description === id) {
|
||||
return node;
|
||||
}
|
||||
for (const child of node.children || []) {
|
||||
const found = findBySemanticId(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Example: Find profile button
|
||||
const profileButton = findBySemanticId(snapshot, 'profile_button');
|
||||
|
||||
// Example: Find a specific data grid row
|
||||
const row = findBySemanticId(snapshot, 'dataGrid_row_abc123');
|
||||
```
|
||||
|
||||
### Using Chrome DevTools MCP
|
||||
|
||||
With the Chrome DevTools MCP server, you can query semantics directly:
|
||||
|
||||
```javascript
|
||||
// Take a snapshot (returns accessibility tree)
|
||||
const snapshot = await mcp__chrome_devtools__take_snapshot();
|
||||
|
||||
// Click by semantic ID (uid in snapshot)
|
||||
await mcp__chrome_devtools__click({ uid: 'profile_button' });
|
||||
|
||||
// Fill input by semantic ID
|
||||
await mcp__chrome_devtools__fill({
|
||||
uid: 'dataGrid_search',
|
||||
value: 'my search query'
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Add Semantics to Interactive Elements
|
||||
|
||||
Every clickable, tappable, or input element should have a semantic identifier:
|
||||
|
||||
```dart
|
||||
// Buttons
|
||||
Semantics(
|
||||
identifier: 'myFeature_submit',
|
||||
button: true,
|
||||
label: 'Submit form',
|
||||
child: ElevatedButton(...),
|
||||
)
|
||||
|
||||
// Text fields
|
||||
Semantics(
|
||||
identifier: 'myFeature_email',
|
||||
textField: true,
|
||||
label: 'Email address',
|
||||
child: TextField(...),
|
||||
)
|
||||
|
||||
// Checkboxes
|
||||
Semantics(
|
||||
identifier: 'myFeature_rememberMe',
|
||||
checked: isChecked,
|
||||
label: 'Remember me',
|
||||
child: Checkbox(...),
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Use Meaningful Labels
|
||||
|
||||
Labels help both accessibility tools and test debugging:
|
||||
|
||||
```dart
|
||||
// Good - descriptive label
|
||||
Semantics(
|
||||
identifier: DataGridSemantics.rowAction(item.id, 'delete'),
|
||||
label: 'Delete ${item.name}',
|
||||
button: true,
|
||||
child: ...,
|
||||
)
|
||||
|
||||
// Bad - no context
|
||||
Semantics(
|
||||
identifier: 'btn1',
|
||||
child: ...,
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Register New IDs Centrally
|
||||
|
||||
Always add new semantic IDs to `semantic_ids.dart`:
|
||||
|
||||
```dart
|
||||
/// My new feature IDs.
|
||||
abstract class MyFeatureSemantics {
|
||||
static const submitButton = 'myFeature_submit';
|
||||
static const cancelButton = 'myFeature_cancel';
|
||||
static const nameField = 'myFeature_name';
|
||||
|
||||
/// Generate ID for a list item.
|
||||
static String item(String id) => 'myFeature_item_$id';
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Test ID Stability
|
||||
|
||||
Semantic IDs should remain stable across releases. When refactoring:
|
||||
|
||||
- Keep existing IDs unchanged
|
||||
- Add deprecation comments if IDs must change
|
||||
- Update automation tests when IDs change
|
||||
|
||||
### 5. Exclude Decorative Elements
|
||||
|
||||
Don't add semantic IDs to purely decorative elements:
|
||||
|
||||
```dart
|
||||
// Decorative icon - no semantics needed
|
||||
Icon(Icons.star, color: Colors.yellow)
|
||||
|
||||
// Interactive icon - needs semantics
|
||||
Semantics(
|
||||
identifier: 'rating_star_3',
|
||||
button: true,
|
||||
label: 'Rate 3 stars',
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.star),
|
||||
onPressed: () => rate(3),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
## DataGrid Semantic Patterns
|
||||
|
||||
The DataGrid component has comprehensive semantic coverage:
|
||||
|
||||
```
|
||||
dataGrid - The grid container
|
||||
dataGrid_search - Search input field
|
||||
dataGrid_search_clear - Clear search button
|
||||
dataGrid_selectAll - Select all checkbox
|
||||
dataGrid_header_{columnId} - Column header (sortable)
|
||||
dataGrid_row_{itemId} - Row container
|
||||
dataGrid_row_{itemId}_checkbox - Row selection checkbox
|
||||
dataGrid_row_{itemId}_actions - Row actions menu trigger
|
||||
dataGrid_row_{itemId}_action_{actionId} - Specific row action
|
||||
dataGrid_bulk_{actionId} - Bulk action button
|
||||
dataGrid_bulk_clear - Clear selection button
|
||||
dataGrid_loading - Loading indicator
|
||||
dataGrid_empty - Empty state message
|
||||
dataGrid_error - Error state message
|
||||
dataGrid_refresh - Refresh button
|
||||
```
|
||||
|
||||
### Example: Automating DataGrid Selection
|
||||
|
||||
```javascript
|
||||
// Select all rows
|
||||
await click('dataGrid_selectAll');
|
||||
|
||||
// Select specific row
|
||||
await click('dataGrid_row_abc123_checkbox');
|
||||
|
||||
// Perform bulk delete
|
||||
await click('dataGrid_bulk_delete');
|
||||
|
||||
// Confirm in dialog
|
||||
await click('dialog_confirm');
|
||||
```
|
||||
|
||||
## Debugging Semantics
|
||||
|
||||
### Flutter DevTools
|
||||
|
||||
1. Open Flutter DevTools
|
||||
2. Go to "Inspector" tab
|
||||
3. Enable "Semantics" overlay
|
||||
4. Click widgets to see their semantic properties
|
||||
|
||||
### Chrome DevTools
|
||||
|
||||
1. Open DevTools (F12)
|
||||
2. Go to "Accessibility" tab
|
||||
3. Inspect the accessibility tree
|
||||
4. Search for semantic identifiers
|
||||
|
||||
### Programmatic Inspection
|
||||
|
||||
```dart
|
||||
// In a test, dump the semantics tree
|
||||
debugDumpSemanticsTree();
|
||||
|
||||
// Check if semantics are enabled
|
||||
print('Semantics enabled: ${SemanticsBinding.instance.semanticsEnabled}');
|
||||
```
|
||||
|
||||
## Integration with URL Routing
|
||||
|
||||
For deep-linkable test scenarios, semantic IDs work with URL query parameters:
|
||||
|
||||
```
|
||||
/control-room/containers?selected=abc123
|
||||
```
|
||||
|
||||
Automation can:
|
||||
1. Navigate to URL with query params
|
||||
2. Verify selection state via `dataGrid_row_abc123_checkbox` (checked: true)
|
||||
3. Interact with selected rows via semantic IDs
|
||||
|
||||
See [URL Routing](#url-routing) section for query parameter patterns.
|
||||
|
||||
## Checklist for New Features
|
||||
|
||||
When adding a new feature, ensure semantic coverage:
|
||||
|
||||
- [ ] Add semantic ID class to `semantic_ids.dart`
|
||||
- [ ] Wrap all buttons with `Semantics` + `identifier`
|
||||
- [ ] Wrap all inputs with `Semantics` + `identifier`
|
||||
- [ ] Wrap list/grid items with dynamic IDs
|
||||
- [ ] Add labels for accessibility
|
||||
- [ ] Test that IDs appear in accessibility snapshot
|
||||
- [ ] Document IDs in this file if they establish new patterns
|
||||
+394
-52
@@ -22,36 +22,286 @@ The UI uses a **tabbed room navigation** in the header rather than a traditional
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ HEADER (intrinsic height) │
|
||||
│ [Logo] [═══════ Room Tabs (scrollable) ═══════] [Notifications] [Profile] │
|
||||
│ HEADER BAR (56px, with logo bulge overlay) │
|
||||
│ [◯Logo◯] [Room Icons] ─────────────────────────────────── [Profile Menu] │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ ┌───────────────────┐ │
|
||||
│ │ │ │ │ │
|
||||
│ │ MAIN CONTENT │ │ CHAT DOCK │ │
|
||||
│ │ flex: 1 │ │ flex: 0 │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ┌─────────────┐ ┌────────────────────────────┐ │ │ Tatlock chat │ │
|
||||
│ │ │ CONTEXT │ │ PRIMARY │ │ │ assistant, │ │
|
||||
│ │ │ SIDEBAR │ │ CONTENT │ │ │ persistent │ │
|
||||
│ │ │ │ │ │ │ │ across rooms │ │
|
||||
│ │ │ flex: 0 │ │ flex: 1 │ │ │ │ │
|
||||
│ │ │ intrinsic │ │ │ │ │ │ │
|
||||
│ │ └─────────────┘ └────────────────────────────┘ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────────────────────────────────────────┘ └───────────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────────────────────────┐ ┌───────────────────┐ │
|
||||
│ │ NAV PANEL │ │ PRIMARY CONTENT │ │ CHAT DOCK │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ Room-level │ │ ┌──────────┐ ┌──────────────┐ │ │ Tatlock AI │ │
|
||||
│ │ navigation │ │ │ FILTER │ │ DATA GRID │ │ │ assistant │ │
|
||||
│ │ (sections) │ │ │ PANEL │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │ Persistent │ │
|
||||
│ │ 280px fixed │ │ │ Optional │ │ flex: 1 │ │ │ across rooms │ │
|
||||
│ │ │ │ │ 280px │ │ │ │ │ │ │
|
||||
│ └─────────────┘ │ └──────────┘ └──────────────┘ │ │ 280px-33vw │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ flex: 1 │ │ flex: 0 │ │
|
||||
│ └─────────────────────────────────┘ └───────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Roles
|
||||
---
|
||||
|
||||
## Panel Taxonomy
|
||||
|
||||
All panels share common styling patterns but serve different purposes.
|
||||
|
||||
### Panel Types
|
||||
|
||||
| Panel | Position | Purpose | Width | Content Alignment |
|
||||
|-------|----------|---------|-------|-------------------|
|
||||
| **Header Bar** | Top | Room nav, profile | 56px height | Logo in bulge, rest at bottom |
|
||||
| **Nav Panel** | Left | Room-level section nav | 280px fixed | Header docked to bottom |
|
||||
| **Filter Panel** | Left (inside content) | Data filtering/search | 280px fixed | Header docked to bottom |
|
||||
| **Detail Panel** | Right (inside content) | Selected item details | 320-400px | Standard header |
|
||||
| **Chat Dock** | Right | AI assistant | 280px-33vw | Standard header |
|
||||
|
||||
### Panel Header Behavior
|
||||
|
||||
All left-side panels (Nav Panel, Filter Panel) dock their header content to the **bottom** of the header area. This accommodates the logo bulge that overlays into their space.
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ │ ← Logo bulge overlays this space
|
||||
│ (empty) │
|
||||
│ │
|
||||
│ [Icon] Title [Action]│ ← Content docked to bottom (8px margin)
|
||||
├─────────────────────────┤
|
||||
│ Panel content... │
|
||||
```
|
||||
|
||||
Right-side panels (Detail Panel, Chat Dock) use standard vertically-centered headers since the logo bulge doesn't reach them.
|
||||
|
||||
### Nav Panel Sections
|
||||
|
||||
Nav items can be organized into **sections**. Section headers are **conditionally visible** - they only appear when multiple sections exist.
|
||||
|
||||
#### Single Section (headers hidden)
|
||||
|
||||
When all items belong to one section, no headers are shown:
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ [≡] Sections [···] │ ← Panel header
|
||||
├─────────────────────────┤
|
||||
│ ▸ Containers │
|
||||
│ Networks │
|
||||
│ Volumes │
|
||||
│ Images │
|
||||
│ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
#### Multiple Sections (headers visible)
|
||||
|
||||
When items span multiple sections, section headers appear:
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ [≡] Sections [···] │ ← Panel header
|
||||
├─────────────────────────┤
|
||||
│ ▌Portainer │ ← Section header (subtle bg, left accent)
|
||||
│ ▸ Containers │
|
||||
│ Networks │
|
||||
│ Volumes │
|
||||
│ Images │
|
||||
│ │
|
||||
│ ▌NPM │ ← Section header
|
||||
│ Proxy Hosts │
|
||||
│ Redirections │
|
||||
│ Streams │
|
||||
│ │
|
||||
│ ▌Authentik │ ← Section header
|
||||
│ Users │
|
||||
│ Groups │
|
||||
│ Applications │
|
||||
│ │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
#### Section Header Styling
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│▌SECTION NAME │ ← Left accent bar (2px, primary color)
|
||||
└─────────────────────────┘ ← Background: surfaceContainerHigh
|
||||
← Text: labelSmall, onSurfaceVariant
|
||||
← Padding: 8px horizontal, 6px vertical
|
||||
← All caps, letter-spacing: 0.5
|
||||
```
|
||||
|
||||
#### Data Model
|
||||
|
||||
```dart
|
||||
/// NavItem model (in nav_panel.dart)
|
||||
class NavItem {
|
||||
final String id;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String? section; // null = ungrouped
|
||||
}
|
||||
|
||||
// Section headers auto-generate from unique section values
|
||||
// Visibility: items.map((i) => i.section).toSet().length > 1
|
||||
```
|
||||
|
||||
#### Route Configuration Driven
|
||||
|
||||
Sections are defined in the feature's route configuration, not the widget:
|
||||
|
||||
```dart
|
||||
/// In lib/features/control_room/router.dart
|
||||
|
||||
enum ControlRoomNav {
|
||||
// Portainer section
|
||||
containers('containers', 'Containers', Icons.dns, 'Portainer'),
|
||||
networks('networks', 'Networks', Icons.hub, 'Portainer'),
|
||||
volumes('volumes', 'Volumes', Icons.storage, 'Portainer'),
|
||||
images('images', 'Images', Icons.photo_library, 'Portainer'),
|
||||
// NPM section (future)
|
||||
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'NPM'),
|
||||
redirections('redirections', 'Redirections', Icons.alt_route, 'NPM'),
|
||||
// Authentik section (future)
|
||||
users('users', 'Users', Icons.people, 'Authentik'),
|
||||
groups('groups', 'Groups', Icons.group_work, 'Authentik');
|
||||
|
||||
const ControlRoomNav(this.id, this.label, this.icon, this.section);
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String section;
|
||||
|
||||
NavItem toNavItem() => NavItem(
|
||||
id: id,
|
||||
label: label,
|
||||
icon: icon,
|
||||
section: section,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The NavPanel widget receives items and auto-generates section headers based on unique section values in the list. No section logic lives in the widget - it just renders what the route config provides.
|
||||
|
||||
---
|
||||
|
||||
## Panel Configurations by Room
|
||||
|
||||
### Front Hall
|
||||
|
||||
Front Hall uses a **three-mode content system** with a persistent QuickLinks panel:
|
||||
|
||||
| Mode | QuickLinks Panel | Content Area |
|
||||
|------|------------------|--------------|
|
||||
| **Dashboard** | Normal navigation | Dashboard widgets (stats, weather) |
|
||||
| **Iframe** | Normal navigation | Embedded iframe content |
|
||||
| **Settings** | Edit mode (back button, add new, overflow menus) | QuickLinkPage (EntityPageScaffold) |
|
||||
|
||||
#### Dashboard Mode (default)
|
||||
```
|
||||
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
|
||||
│ QUICK LINKS │ PRIMARY CONTENT │ CHAT DOCK │
|
||||
│ │ │ (expanded) │
|
||||
│ ▌HOME │ SYSTEM STATS │ │
|
||||
│ Jellyfin │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
|
||||
│ WebUI │ │ CPU │ │ MEM │ │ DISK │ │ NET │ │ │
|
||||
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
|
||||
│ ▌AMP │ │ │
|
||||
│ AMP Home │ WEATHER AIR QUALITY │ │
|
||||
│ │ ┌────────────────┐ ┌────────────────┐ │ │
|
||||
│ ▌INFRA │ │ 72°F Sunny │ │ AQI: 42 Good │ │ │
|
||||
│ Netdata │ └────────────────┘ └────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ [⚙ Settings] │ │ │
|
||||
└──────────────┴─────────────────────────────────────────────────┴────────────┘
|
||||
```
|
||||
|
||||
#### Iframe Mode
|
||||
```
|
||||
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
|
||||
│ QUICK LINKS │ ┌─────────────────────────────────────────────┐ │ CHAT DOCK │
|
||||
│ │ │ Jellyfin [↗] [⟳] [✕] │ │ (collapsed)│
|
||||
│ ▌HOME │ ├─────────────────────────────────────────────┤ │ │
|
||||
│ ▸Jellyfin │ │ │ │ │
|
||||
│ WebUI │ │ <iframe src="jellyfin.url"> │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ▌AMP │ │ │ │ │
|
||||
│ AMP Home │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ▌INFRA │ │ │ │ │
|
||||
│ Netdata │ │ │ │ │
|
||||
│ │ └─────────────────────────────────────────────┘ │ │
|
||||
│ [⚙ Settings] │ │ │
|
||||
└──────────────┴─────────────────────────────────────────────────┴────────────┘
|
||||
```
|
||||
|
||||
#### Settings Mode
|
||||
```
|
||||
┌──────────────┬─────────────────────────────────────────────────┬────────────┐
|
||||
│ [←] Links │ ┌─────────────────────────────────────────────┐ │ CHAT DOCK │
|
||||
│ │ │ ← Back Edit Quick Link [Save] │ │ (collapsed)│
|
||||
│ [+ Add New] │ ├─────────────────────────────────────────────┤ │ │
|
||||
├──────────────┤ │ │ │ │
|
||||
│ ▌HOME │ │ Name: [Jellyfin_________________] │ │ │
|
||||
│ ▸Jellyfin ⋮ │ │ │ │ │
|
||||
│ WebUI ⋮ │ │ URL: [https://jellyfin.schweitz...] │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ▌AMP │ │ Category: [Home ▼] │ │ │
|
||||
│ AMP Home ⋮ │ │ │ │ │
|
||||
│ │ │ Type: ◉ Iframe ○ New Tab │ │ │
|
||||
│ ▌INFRA │ │ │ │ │
|
||||
│ Netdata ⋮ │ │ Icon: [🎬 Pick...] │ │ │
|
||||
│ Portainer⋮ │ │ │ │ │
|
||||
│ │ │ Active: [✓] │ │ │
|
||||
└──────────────┴─────────────────────────────────────────────────┴────────────┘
|
||||
```
|
||||
|
||||
**Settings mode panel behavior:**
|
||||
- Back button in header (exits settings mode → dashboard)
|
||||
- "+ Add New Link" button at top
|
||||
- Each item has overflow menu (Edit, Delete, Move Up/Down)
|
||||
- Clicking item selects it for editing in content area
|
||||
- Uses EntityPageScaffold pattern (no modals)
|
||||
|
||||
### Control Room
|
||||
```
|
||||
┌───────────┬──────────────────────────────────────────────────────┬──────────┐
|
||||
│ NAV PANEL │ PRIMARY CONTENT │ CHAT │
|
||||
│ │ ┌────────────┬────────────────────────────────────┐ │ DOCK │
|
||||
│ Sections: │ │ FILTER │ DATA GRID │ │ │
|
||||
│ • Contai. │ │ PANEL │ Container/Stack list │ │ (collap- │
|
||||
│ • Stacks │ │ │ │ │ sed) │
|
||||
│ • Network │ │ Stack list │ │ │ │
|
||||
│ • Volumes │ │ + search │ │ │ │
|
||||
└───────────┴──┴────────────┴────────────────────────────────────┴─┴──────────┘
|
||||
```
|
||||
|
||||
### Parlor
|
||||
```
|
||||
┌───────────┬────────────────────────────────────────────────────┬────────────┐
|
||||
│ NAV PANEL │ PRIMARY CONTENT │ CHAT DOCK │
|
||||
│ │ Device controls, scenes │ (collapsed)│
|
||||
│ Areas: │ │ │
|
||||
│ • Living │ │ │
|
||||
│ • Bedroom │ │ │
|
||||
│ • Kitchen │ │ │
|
||||
└───────────┴────────────────────────────────────────────────────┴────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Roles
|
||||
|
||||
| Component | Flex | Description |
|
||||
|-----------|------|-------------|
|
||||
| **Header** | intrinsic | Logo, room tabs, action icons |
|
||||
| **Main Content** | `flex: 1` | Fills remaining horizontal space |
|
||||
| **Context Sidebar** | `flex: 0`, intrinsic | Room-specific navigation (Control Room, Parlor) |
|
||||
| **Primary Content** | `flex: 1` | Room's main working area |
|
||||
| **Header Bar** | intrinsic (56px) | Logo bulge, room icons, profile menu |
|
||||
| **Nav Panel** | `flex: 0`, 280px | Room-level section navigation |
|
||||
| **Filter Panel** | `flex: 0`, 280px | Data filtering within a section |
|
||||
| **Primary Content** | `flex: 1` | Main working area |
|
||||
| **Detail Panel** | `flex: 0`, 320-400px | Selected item details (optional) |
|
||||
| **Chat Dock** | `flex: 0`, intrinsic | Tatlock assistant, collapsible |
|
||||
|
||||
---
|
||||
@@ -61,55 +311,76 @@ The UI uses a **tabbed room navigation** in the header rather than a traditional
|
||||
### Wide (>= 1200px)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┬────────────────────┐
|
||||
│ [Sidebar] [══════════ Content ══════════] │ Chat (expanded) │
|
||||
└────────────────────────────────────────────────────────┴────────────────────┘
|
||||
┌───────────┬─────────────────────────────────────────────┬────────────────────┐
|
||||
│ NAV PANEL │ [Filter Panel] [═══ Data Grid ═══] │ CHAT DOCK │
|
||||
│ (280px) │ (280px) (flex: 1) │ (expanded, 320px) │
|
||||
└───────────┴─────────────────────────────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
- All panels visible
|
||||
- Chat dock expanded by default on Front Hall
|
||||
- Context sidebar visible with labels
|
||||
- Nav panel visible with full labels
|
||||
- Filter panel visible (where applicable)
|
||||
- Full data grid columns
|
||||
|
||||
### Medium (>= 800px, < 1200px)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┬────┐
|
||||
│ [Sidebar] [══════════════════ Content ══════════════════] │ 💬 │
|
||||
└────────────────────────────────────────────────────────────────────────┴────┘
|
||||
┌───────────┬───────────────────────────────────────────────────────────┬────┐
|
||||
│ NAV PANEL │ [Filter Panel] [═══════════ Data Grid ═══════════] │ 💬 │
|
||||
│ (280px) │ (280px) (flex: 1) │48px│
|
||||
└───────────┴───────────────────────────────────────────────────────────┴────┘
|
||||
```
|
||||
|
||||
- Chat dock collapsed to icon rail
|
||||
- Click to expand as overlay
|
||||
- Context sidebar still visible
|
||||
- Chat dock collapsed to icon rail (48px)
|
||||
- Click chat icon to expand as overlay
|
||||
- Nav panel still visible
|
||||
- Filter panel still visible
|
||||
- Data grid may hide some columns
|
||||
|
||||
### Compact (>= 600px, < 800px)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┬────┐
|
||||
│ [≡] [════════════════════ Content ════════════════════] │ 💬 │
|
||||
└────────────────────────────────────────────────────────────────────────┴────┘
|
||||
┌────┬───────────────────────────────────────────────────────────────────┬────┐
|
||||
│ ≡ │ [═══════════════════════ Content ═══════════════════════] │ 💬 │
|
||||
│48px│ Filter panel becomes top bar or collapsible │48px│
|
||||
└────┴───────────────────────────────────────────────────────────────────┴────┘
|
||||
```
|
||||
|
||||
- Context sidebar becomes drawer (hamburger menu)
|
||||
- Chat dock collapsed
|
||||
- Reduced data grid columns
|
||||
- Nav panel collapses to icon rail (48px), expands as drawer on tap
|
||||
- Filter panel moves to top of content or becomes collapsible
|
||||
- Chat dock remains collapsed (48px)
|
||||
- Data grid shows essential columns only
|
||||
|
||||
### Mobile (< 600px)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ [≡] [Tabs scroll horizontally] [💬] │
|
||||
│ HEADER: [≡] [Room Icons scroll] ─────────────────────────────── [💬] │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [══════════════════════ Content ══════════════════════] │
|
||||
│ [═══════════════════════ Content ══════════════════════] │
|
||||
│ Filter as expandable section at top │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Single column layout
|
||||
- Room tabs scroll horizontally
|
||||
- Chat opens as bottom sheet
|
||||
- Context sidebar as full-screen drawer
|
||||
- Nav panel opens as full-screen drawer (hamburger menu)
|
||||
- Filter panel becomes expandable section at top of content
|
||||
- Chat opens as bottom sheet (60vh max)
|
||||
- Data grid becomes card list or single-column table
|
||||
|
||||
---
|
||||
|
||||
## Panel Folding Summary
|
||||
|
||||
| Panel | Wide (≥1200) | Medium (≥800) | Compact (≥600) | Mobile (<600) |
|
||||
|-------|--------------|---------------|----------------|---------------|
|
||||
| **Nav Panel** | 280px visible | 280px visible | 48px rail → drawer | Hidden → drawer |
|
||||
| **Filter Panel** | 280px visible | 280px visible | Collapsed/top bar | Expandable section |
|
||||
| **Chat Dock** | 320px expanded | 48px rail → overlay | 48px rail → overlay | Icon → bottom sheet |
|
||||
| **Detail Panel** | 320-400px slide-in | 320px slide-in | Full-width overlay | Full-screen |
|
||||
|
||||
---
|
||||
|
||||
@@ -168,29 +439,51 @@ Primary landing page. Chat dock expanded by default.
|
||||
|
||||
### Control Room (Infrastructure)
|
||||
|
||||
Context sidebar with section navigation. Chat collapsed.
|
||||
Nav panel with grouped section navigation. Chat collapsed.
|
||||
|
||||
**Current state** (single grouper - headers hidden):
|
||||
```
|
||||
┌───────────────┬───────────────────────────────────────────────────────┬──────┐
|
||||
│ │ │ │
|
||||
│ SECTIONS │ CONTAINERS [Search] [+ New] │ 💬 │
|
||||
│ │ ─────────────────────────────────────────────────── │ │
|
||||
│ ▸ Containers │ ☑ NAME STATUS CPU MEM IMAGE ⋮ │ │
|
||||
│ Stacks │ ☐ jellyfin ● Run 2.3% 1.2GB latest ⋮ │ │
|
||||
│ Networks │ ☐ jellyfin ● Run 2.3% 1.2GB latest ⋮ │ │
|
||||
│ Volumes │ ☐ ollama ● Run 45% 8.0GB 0.1.32 ⋮ │ │
|
||||
│ Images │ ☐ postgres ● Run 1.1% 512MB 16-alp ⋮ │ │
|
||||
│ │ ☐ redis ● Run 0.2% 128MB 7-alp ⋮ │ │
|
||||
│ │ ─────────────────────────────────────────────────── │ │
|
||||
│ │ Showing 5 of 40 < 1 2 3 4 5 > │ │
|
||||
│ │ │ │
|
||||
└───────────────┴───────────────────────────────────────────────────────┴──────┘
|
||||
```
|
||||
|
||||
**Future state** (multiple groupers - headers visible):
|
||||
```
|
||||
┌───────────────┬───────────────────────────────────────────────────────┬──────┐
|
||||
│ │ │ │
|
||||
│ SECTIONS │ CONTAINERS [Search] [+ New] │ 💬 │
|
||||
│ │ ─────────────────────────────────────────────────── │ │
|
||||
│ ▌PORTAINER │ ☑ NAME STATUS CPU MEM IMAGE ⋮ │ │
|
||||
│ ▸ Containers │ ☐ jellyfin ● Run 2.3% 1.2GB latest ⋮ │ │
|
||||
│ Networks │ ☐ ollama ● Run 45% 8.0GB 0.1.32 ⋮ │ │
|
||||
│ Volumes │ ☐ postgres ● Run 1.1% 512MB 16-alp ⋮ │ │
|
||||
│ Images │ ☐ redis ● Run 0.2% 128MB 7-alp ⋮ │ │
|
||||
│ │ ☐ authentik ○ Stop - - 2024.2 ⋮ │ │
|
||||
│ ──────────── │ ─────────────────────────────────────────────────── │ │
|
||||
│ Netdata ↗ │ Showing 5 of 40 < 1 2 3 4 5 > │ │
|
||||
│ Portainer↗ │ │ │
|
||||
│ NPM ↗ │ │ │
|
||||
│ Volumes │ ─────────────────────────────────────────────────── │ │
|
||||
│ Images │ Showing 3 of 40 < 1 2 3 4 5 > │ │
|
||||
│ │ │ │
|
||||
│ ▌NPM │ │ │
|
||||
│ Proxy Hosts│ │ │
|
||||
│ Redirects │ │ │
|
||||
│ │ │ │
|
||||
│ ▌AUTHENTIK │ │ │
|
||||
│ Users │ │ │
|
||||
│ Groups │ │ │
|
||||
│ │ │ │
|
||||
└───────────────┴───────────────────────────────────────────────────────┴──────┘
|
||||
```
|
||||
|
||||
**Components:**
|
||||
- Context Sidebar: Section nav + external links
|
||||
- Nav Panel: Grouped section navigation (grouper headers conditional)
|
||||
- Filter Panel: Stack/item filtering within section
|
||||
- DataGrid: Container list with bulk actions
|
||||
- Detail Panel: Opens on row selection (replaces grid or slides in)
|
||||
|
||||
@@ -275,6 +568,42 @@ The Tatlock chat assistant is **omnipresent** - accessible from any room.
|
||||
|
||||
## Flutter Implementation Notes
|
||||
|
||||
### Panel Widget Library
|
||||
|
||||
All panels are built from shared components in `lib/shared/layouts/widgets/`:
|
||||
|
||||
| Widget | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| `PanelContainer` | `panel_container.dart` | Base container for all panels |
|
||||
| `PanelHeader` | `panel_header.dart` | Configurable header (bottom-docked or centered) |
|
||||
| `NavPanel` | `nav_panel.dart` | Left-side room navigation |
|
||||
| `FilterPanel` | `filter_panel.dart` | Left-side data filtering |
|
||||
| `DetailPanel` | `detail_panel.dart` | Right-side item details |
|
||||
| `ChatDock` | `chat_dock.dart` | Right-side AI assistant |
|
||||
|
||||
### PanelHeader Configuration
|
||||
|
||||
```dart
|
||||
/// Panel header with configurable content alignment.
|
||||
class PanelHeader extends StatelessWidget {
|
||||
const PanelHeader({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
this.actions,
|
||||
this.dockToBottom = false, // true for left-side panels
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Left-side panels** (Nav, Filter) use `dockToBottom: true` to accommodate the logo bulge:
|
||||
- Header height: 56px (matches app header)
|
||||
- Content aligned to bottom with 8px margin
|
||||
- Empty space at top allows logo bulge overlay
|
||||
|
||||
**Right-side panels** (Detail, Chat) use `dockToBottom: false`:
|
||||
- Standard vertically-centered content
|
||||
- No accommodation needed for logo bulge
|
||||
|
||||
### Recommended Widgets
|
||||
|
||||
| Concept | Flutter Widget |
|
||||
@@ -296,6 +625,19 @@ abstract class Breakpoints {
|
||||
}
|
||||
```
|
||||
|
||||
### Panel Width Constants
|
||||
|
||||
```dart
|
||||
abstract class PanelWidths {
|
||||
static const double navPanel = 280;
|
||||
static const double filterPanel = 280;
|
||||
static const double detailPanel = 360;
|
||||
static const double chatDockExpanded = 320;
|
||||
static const double chatDockCollapsed = 48;
|
||||
static const double railWidth = 48;
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Builder Pattern
|
||||
|
||||
```dart
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Decisions, Questions, Rejected
|
||||
|
||||
This directory holds structured planning records that pql parses
|
||||
into pql.db. Each record is a `### [DQR]-N: Title` heading inside
|
||||
a markdown file. Files live in three per-type subdirectories:
|
||||
|
||||
- `decisions/<domain>.md` — confirmed design decisions
|
||||
- `questions/<domain>.md` — open questions that may resolve into
|
||||
decisions or rejected proposals
|
||||
- `rejected/<domain>.md` — rejected proposals (kept for the audit
|
||||
trail)
|
||||
|
||||
The parser infers domain from the filename stem and record type
|
||||
from the parent subdirectory.
|
||||
|
||||
D-records that propose implementation work link to `initiative`-type
|
||||
tickets via `decision_ref`. Run `pql decisions show <id>
|
||||
--with-tickets` to inspect implementation status.
|
||||
|
||||
## Recommended domains
|
||||
|
||||
Start with this canonical set; create files as records land in
|
||||
each domain:
|
||||
|
||||
- **architecture** — structural commitments (storage, layering,
|
||||
languages, libraries)
|
||||
- **process** — team workflow (commits, branches, releases, reviews)
|
||||
- **design** — user-facing surface (UX, UI, public APIs)
|
||||
- **coding-conventions** — team-internal code shape (style, lint,
|
||||
file layout)
|
||||
- **testing** — quality strategy (coverage, layers, gates)
|
||||
|
||||
You might also want, project-permitting:
|
||||
|
||||
- `accessibility` — if you ship user-facing software
|
||||
- `security` — if you handle user data or network surfaces
|
||||
- `licensing` — if you release open-source or commercial
|
||||
- `documentation` — if user-docs are non-trivial
|
||||
- `deployment` — if shipping is non-trivial
|
||||
- `performance` — if you have perf budgets / SLOs
|
||||
|
||||
<!-- pql:records (auto-generated; do not edit manually) -->
|
||||
|
||||
## Decisions
|
||||
|
||||
- _(none)_
|
||||
|
||||
## Open questions
|
||||
|
||||
- _(none)_
|
||||
|
||||
## Rejected
|
||||
|
||||
- _(none)_
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
end
|
||||
end
|
||||
@@ -45,5 +45,18 @@
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>net.schweitz.tatlock</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>net.schweitz.tatlock</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ class TatlockApp extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
final themeAsync = ref.watch(themeNotifierProvider);
|
||||
final themeAsync = ref.watch(themeProvider);
|
||||
|
||||
// Get theme mode, defaulting to system while loading
|
||||
final themeMode = switch (themeAsync) {
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_interceptors.dart';
|
||||
import 'package:tatlock_ui/core/config/app_config.dart';
|
||||
import 'package:tatlock_ui/core/providers/annotations.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import 'api_interceptors.dart';
|
||||
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
|
||||
as platform;
|
||||
|
||||
part 'api_client.g.dart';
|
||||
|
||||
/// Provides the Dio instance for Core API.
|
||||
@riverpod
|
||||
Dio coreApiClient(CoreApiClientRef ref) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.coreApiUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
@persistentRiverpod
|
||||
Dio coreApiClient(Ref ref) {
|
||||
final options = BaseOptions(
|
||||
baseUrl: AppConfig.coreApiUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
final dio = platform.createDio(options);
|
||||
|
||||
dio.interceptors.addAll([
|
||||
AuthInterceptor(ref),
|
||||
LoggingInterceptor(),
|
||||
@@ -31,20 +34,20 @@ Dio coreApiClient(CoreApiClientRef ref) {
|
||||
}
|
||||
|
||||
/// Provides the Dio instance for Tatlock API.
|
||||
@riverpod
|
||||
Dio tatlockApiClient(TatlockApiClientRef ref) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.tatlockApiUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
@persistentRiverpod
|
||||
Dio tatlockApiClient(Ref ref) {
|
||||
final options = BaseOptions(
|
||||
baseUrl: AppConfig.tatlockApiUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
final dio = platform.createDio(options);
|
||||
|
||||
dio.interceptors.addAll([
|
||||
AuthInterceptor(ref),
|
||||
LoggingInterceptor(),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Create a Dio instance for native platforms (mobile, desktop).
|
||||
Dio createDio(BaseOptions options) {
|
||||
return Dio(options);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio_web_adapter/dio_web_adapter.dart';
|
||||
|
||||
/// Create a Dio instance for web platform with credentials support.
|
||||
///
|
||||
/// Enables `withCredentials` to send cookies with requests, which is
|
||||
/// required for NPM forward auth to work correctly.
|
||||
Dio createDio(BaseOptions options) {
|
||||
final dio = Dio(options);
|
||||
dio.httpClientAdapter = BrowserHttpClientAdapter(withCredentials: true);
|
||||
return dio;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../error/app_exception.dart';
|
||||
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
||||
import 'package:tatlock_ui/core/config/app_config.dart';
|
||||
import 'package:tatlock_ui/core/error/app_exception.dart';
|
||||
|
||||
/// Adds authentication token to requests.
|
||||
///
|
||||
/// - **LAN mode**: Skipped entirely (no auth required)
|
||||
/// - **Web + Mobile**: Adds Bearer token from OIDC authentication
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._ref);
|
||||
|
||||
@@ -14,7 +18,14 @@ class AuthInterceptor extends Interceptor {
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final authState = _ref.read(authNotifierProvider);
|
||||
// Skip auth for LAN development
|
||||
if (!AppConfig.requiresAuth) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add Bearer token for all platforms (web + mobile)
|
||||
final authState = _ref.read(authProvider);
|
||||
|
||||
authState.whenData((auth) {
|
||||
if (auth.isAuthenticated && auth.accessToken != null) {
|
||||
@@ -27,41 +38,66 @@ class AuthInterceptor extends Interceptor {
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (err.response?.statusCode == 401) {
|
||||
// Token expired - trigger re-authentication
|
||||
_ref.read(authNotifierProvider.notifier).signOut();
|
||||
}
|
||||
// Don't auto-signout on 401 - let calling code handle auth errors gracefully.
|
||||
// Auto-signout was causing issues (e.g., theme toggle triggering logout when
|
||||
// preferences API returned 401).
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs requests and responses in debug mode.
|
||||
/// Logs API requests and responses to the console.
|
||||
///
|
||||
/// All requests are logged with method, URL, query params, and body.
|
||||
/// Responses include status code. Errors include full details.
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
developer.log(
|
||||
'→ ${options.method} ${options.uri}',
|
||||
name: 'api',
|
||||
);
|
||||
final buffer = StringBuffer()
|
||||
..writeln('┌── API Request ──────────────────────────────────────')
|
||||
..writeln('│ ${options.method} ${options.path}');
|
||||
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
buffer.writeln('│ Query: ${options.queryParameters}');
|
||||
}
|
||||
|
||||
if (options.data != null) {
|
||||
buffer.writeln('│ Body: ${options.data}');
|
||||
}
|
||||
|
||||
buffer.writeln('└─────────────────────────────────────────────────────');
|
||||
|
||||
final message = buffer.toString();
|
||||
developer.log(message, name: 'API');
|
||||
debugPrint(message);
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
developer.log(
|
||||
'← ${response.statusCode} ${response.requestOptions.uri}',
|
||||
name: 'api',
|
||||
);
|
||||
final message =
|
||||
'✓ ${response.statusCode} ${response.requestOptions.method} ${response.requestOptions.path}';
|
||||
developer.log(message, name: 'API');
|
||||
debugPrint(message);
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
developer.log(
|
||||
'✗ ${err.response?.statusCode ?? 'NETWORK'} ${err.requestOptions.uri}: ${err.message}',
|
||||
name: 'api',
|
||||
error: err,
|
||||
);
|
||||
final buffer = StringBuffer()
|
||||
..writeln('┌── API Error ────────────────────────────────────────')
|
||||
..writeln('│ ${err.requestOptions.method} ${err.requestOptions.path}')
|
||||
..writeln('│ Status: ${err.response?.statusCode ?? 'NETWORK ERROR'}')
|
||||
..writeln('│ Message: ${err.message}');
|
||||
|
||||
if (err.response?.data != null) {
|
||||
buffer.writeln('│ Response: ${err.response?.data}');
|
||||
}
|
||||
|
||||
buffer.writeln('└─────────────────────────────────────────────────────');
|
||||
|
||||
final message = buffer.toString();
|
||||
developer.log(message, name: 'API', error: err);
|
||||
debugPrint(message);
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
@@ -127,7 +163,10 @@ class ErrorInterceptor extends Interceptor {
|
||||
case DioExceptionType.badCertificate:
|
||||
return const NetworkException(message: 'Invalid SSL certificate');
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
// default keeps this exhaustive across dio versions (CI resolves deps
|
||||
// fresh — pubspec.lock is gitignored — so DioExceptionType can gain
|
||||
// cases, e.g. transformTimeout in dio >= 5.8)
|
||||
default:
|
||||
return NetworkException(
|
||||
message: err.message ?? 'Unknown error',
|
||||
cause: err,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import 'dart:convert' show jsonDecode, jsonEncode;
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import 'auth_datasource.dart';
|
||||
import 'auth_state.dart';
|
||||
import 'oidc_service_web.dart';
|
||||
import 'permissions.dart';
|
||||
import 'user_preferences.dart';
|
||||
import 'web_utils.dart' as web_utils;
|
||||
|
||||
/// Standalone auth controller that handles OIDC flow before app starts.
|
||||
///
|
||||
/// This runs outside of Riverpod to avoid lifecycle issues. Call [initialize]
|
||||
/// in main() before runApp(). The controller will:
|
||||
/// 1. Handle callback if on /callback route (exchange code, sync, store tokens)
|
||||
/// 2. Check for valid stored tokens
|
||||
/// 3. Redirect to silent OIDC if no tokens (app won't continue)
|
||||
///
|
||||
/// Once auth is complete, [AuthProvider] can simply read the stored tokens.
|
||||
class AuthController {
|
||||
// Storage keys (same as AuthProvider)
|
||||
static const _accessTokenKey = 'auth_access_token';
|
||||
static const _refreshTokenKey = 'auth_refresh_token';
|
||||
static const _expiresAtKey = 'auth_expires_at';
|
||||
static const _userIdKey = 'auth_user_id';
|
||||
static const _authentikIdKey = 'auth_authentik_id';
|
||||
static const _userNameKey = 'auth_user_name';
|
||||
static const _userEmailKey = 'auth_user_email';
|
||||
static const _avatarUrlKey = 'auth_avatar_url';
|
||||
static const _rolesKey = 'auth_roles';
|
||||
static const _preferencesKey = 'auth_preferences';
|
||||
|
||||
/// Initialize auth before app starts.
|
||||
///
|
||||
/// Returns true if auth is ready (tokens available).
|
||||
/// Returns false if redirecting (app should not continue).
|
||||
/// Throws on error.
|
||||
static Future<bool> initialize() async {
|
||||
// Skip auth entirely for LAN mode
|
||||
if (!AppConfig.requiresAuth) {
|
||||
developer.log('Auth not required (LAN mode)', name: 'auth_controller');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only handle web auth here - mobile uses different flow
|
||||
if (!kIsWeb) {
|
||||
developer.log('Non-web platform, skipping controller init', name: 'auth_controller');
|
||||
return true;
|
||||
}
|
||||
|
||||
final currentUrl = web_utils.getCurrentUrl();
|
||||
developer.log('Auth controller init, URL: $currentUrl', name: 'auth_controller');
|
||||
|
||||
// Check if we're on the callback route
|
||||
if (currentUrl.contains('/callback')) {
|
||||
return _handleCallback(currentUrl);
|
||||
}
|
||||
|
||||
// Check for valid stored tokens
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final accessToken = prefs.getString(_accessTokenKey);
|
||||
if (accessToken != null) {
|
||||
final expiresAtMs = prefs.getInt(_expiresAtKey);
|
||||
final expiresAt = expiresAtMs != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
|
||||
: null;
|
||||
|
||||
if (expiresAt == null || expiresAt.isAfter(DateTime.now())) {
|
||||
developer.log('Valid tokens found', name: 'auth_controller');
|
||||
return true; // Auth ready
|
||||
}
|
||||
developer.log('Tokens expired', name: 'auth_controller');
|
||||
}
|
||||
|
||||
// No valid tokens - initiate silent OIDC
|
||||
developer.log('No valid tokens, starting silent OIDC', name: 'auth_controller');
|
||||
await _initiateSilentOidc();
|
||||
return false; // Redirecting, app should not continue
|
||||
}
|
||||
|
||||
/// Handle the OIDC callback.
|
||||
static Future<bool> _handleCallback(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
final code = uri.queryParameters['code'];
|
||||
final state = uri.queryParameters['state'];
|
||||
final error = uri.queryParameters['error'];
|
||||
|
||||
developer.log('Handling callback: code=${code != null}, error=$error', name: 'auth_controller');
|
||||
|
||||
// Handle errors
|
||||
if (error != null) {
|
||||
if (error == 'login_required') {
|
||||
// Silent auth failed - no session, start regular OIDC
|
||||
developer.log('Silent auth failed (login_required), starting regular OIDC', name: 'auth_controller');
|
||||
await _initiateRegularOidc();
|
||||
return false;
|
||||
}
|
||||
throw Exception('Auth error: $error - ${uri.queryParameters['error_description']}');
|
||||
}
|
||||
|
||||
if (code == null || state == null) {
|
||||
throw Exception('Invalid callback - missing code or state');
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
developer.log('Exchanging code for tokens', name: 'auth_controller');
|
||||
final oidcService = OidcServiceWeb();
|
||||
final tokens = await oidcService.exchangeCode(code, state);
|
||||
|
||||
// Sync with core-api
|
||||
developer.log('Syncing with core-api', name: 'auth_controller');
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.coreApiUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
));
|
||||
final authDatasource = AuthDatasource(dio);
|
||||
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
|
||||
|
||||
developer.log('Synced user: ${syncResponse.name}', name: 'auth_controller');
|
||||
|
||||
// Store credentials
|
||||
await _storeAuth(
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
userId: syncResponse.userId,
|
||||
authentikId: syncResponse.authentikId,
|
||||
userName: syncResponse.name,
|
||||
userEmail: syncResponse.email,
|
||||
avatarUrl: syncResponse.avatarUrl,
|
||||
roles: syncResponse.roles,
|
||||
preferences: syncResponse.preferences,
|
||||
);
|
||||
|
||||
// Redirect to home (removes callback params from URL)
|
||||
developer.log('Auth complete, redirecting to home', name: 'auth_controller');
|
||||
web_utils.redirectTo('/');
|
||||
return false; // Redirecting
|
||||
}
|
||||
|
||||
/// Initiate silent OIDC (prompt=none).
|
||||
static Future<void> _initiateSilentOidc() async {
|
||||
final oidcService = OidcServiceWeb();
|
||||
final authUrl = await oidcService.getAuthorizationUrl(silent: true);
|
||||
developer.log('Redirecting to silent OIDC', name: 'auth_controller');
|
||||
web_utils.redirectTo(authUrl);
|
||||
}
|
||||
|
||||
/// Initiate regular OIDC (shows login UI).
|
||||
static Future<void> _initiateRegularOidc() async {
|
||||
final oidcService = OidcServiceWeb();
|
||||
final authUrl = await oidcService.getAuthorizationUrl(silent: false);
|
||||
developer.log('Redirecting to regular OIDC', name: 'auth_controller');
|
||||
web_utils.redirectTo(authUrl);
|
||||
}
|
||||
|
||||
/// Store auth data.
|
||||
static Future<void> _storeAuth({
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
DateTime? expiresAt,
|
||||
String? userId,
|
||||
String? authentikId,
|
||||
String? userName,
|
||||
String? userEmail,
|
||||
String? avatarUrl,
|
||||
List<Role>? roles,
|
||||
UserPreferences? preferences,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setString(_accessTokenKey, accessToken);
|
||||
if (refreshToken != null) {
|
||||
await prefs.setString(_refreshTokenKey, refreshToken);
|
||||
}
|
||||
if (expiresAt != null) {
|
||||
await prefs.setInt(_expiresAtKey, expiresAt.millisecondsSinceEpoch);
|
||||
}
|
||||
if (userId != null) await prefs.setString(_userIdKey, userId);
|
||||
if (authentikId != null) await prefs.setString(_authentikIdKey, authentikId);
|
||||
if (userName != null) await prefs.setString(_userNameKey, userName);
|
||||
if (userEmail != null) await prefs.setString(_userEmailKey, userEmail);
|
||||
if (avatarUrl != null) await prefs.setString(_avatarUrlKey, avatarUrl);
|
||||
|
||||
if (roles != null) {
|
||||
final rolesJson = jsonEncode(roles.map((r) => {
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'domain': r.domain.value,
|
||||
'category': r.category,
|
||||
'action': r.action.name,
|
||||
}).toList());
|
||||
await prefs.setString(_rolesKey, rolesJson);
|
||||
}
|
||||
|
||||
if (preferences != null) {
|
||||
await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Load stored auth state (for AuthProvider to use).
|
||||
static Future<AuthState> loadStoredAuth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final accessToken = prefs.getString(_accessTokenKey);
|
||||
if (accessToken == null) {
|
||||
return const AuthState();
|
||||
}
|
||||
|
||||
final expiresAtMs = prefs.getInt(_expiresAtKey);
|
||||
final expiresAt = expiresAtMs != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
|
||||
: null;
|
||||
|
||||
final rolesJson = prefs.getString(_rolesKey);
|
||||
final roles = rolesJson != null ? _parseRoles(rolesJson) : <Role>[];
|
||||
|
||||
final prefsJson = prefs.getString(_preferencesKey);
|
||||
final preferences = prefsJson != null
|
||||
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
|
||||
: null;
|
||||
|
||||
return AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: accessToken,
|
||||
refreshToken: prefs.getString(_refreshTokenKey),
|
||||
expiresAt: expiresAt,
|
||||
userId: prefs.getString(_userIdKey),
|
||||
authentikId: prefs.getString(_authentikIdKey),
|
||||
userName: prefs.getString(_userNameKey),
|
||||
userEmail: prefs.getString(_userEmailKey),
|
||||
avatarUrl: prefs.getString(_avatarUrlKey),
|
||||
roles: roles,
|
||||
preferences: preferences,
|
||||
);
|
||||
} catch (e) {
|
||||
developer.log('Failed to load stored auth: $e', name: 'auth_controller');
|
||||
return const AuthState();
|
||||
}
|
||||
}
|
||||
|
||||
static List<Role> _parseRoles(String json) {
|
||||
try {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list.map((item) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final domain = Domain.fromString(map['domain'] as String);
|
||||
final action = Action.fromString(map['action'] as String);
|
||||
|
||||
if (domain == null || action == null) return null;
|
||||
|
||||
return Role(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String,
|
||||
domain: domain,
|
||||
category: map['category'] as String? ?? 'general',
|
||||
action: action,
|
||||
);
|
||||
}).whereType<Role>().toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear stored auth (for logout).
|
||||
static Future<void> clearAuth() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_accessTokenKey);
|
||||
await prefs.remove(_refreshTokenKey);
|
||||
await prefs.remove(_expiresAtKey);
|
||||
await prefs.remove(_userIdKey);
|
||||
await prefs.remove(_authentikIdKey);
|
||||
await prefs.remove(_userNameKey);
|
||||
await prefs.remove(_userEmailKey);
|
||||
await prefs.remove(_avatarUrlKey);
|
||||
await prefs.remove(_rolesKey);
|
||||
await prefs.remove(_preferencesKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../api/api_client.dart';
|
||||
import 'permissions.dart';
|
||||
import 'user_preferences.dart';
|
||||
|
||||
part 'auth_datasource.g.dart';
|
||||
|
||||
/// Response from POST /auth/sync endpoint.
|
||||
class AuthSyncResponse {
|
||||
const AuthSyncResponse({
|
||||
required this.userId,
|
||||
required this.authentikId,
|
||||
required this.email,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
required this.roles,
|
||||
required this.preferences,
|
||||
required this.isNewUser,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String authentikId;
|
||||
final String email;
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
final List<Role> roles;
|
||||
final UserPreferences preferences;
|
||||
final bool isNewUser;
|
||||
|
||||
factory AuthSyncResponse.fromJson(Map<String, dynamic> json) {
|
||||
final user = json['user'] as Map<String, dynamic>;
|
||||
final rolesJson = json['roles'] as List<dynamic>;
|
||||
final prefsJson = json['preferences'] as Map<String, dynamic>;
|
||||
|
||||
return AuthSyncResponse(
|
||||
userId: user['id'] as String,
|
||||
authentikId: user['authentik_id'] as String,
|
||||
email: user['email'] as String,
|
||||
name: user['name'] as String,
|
||||
avatarUrl: user['avatar_url'] as String?,
|
||||
roles: rolesJson.map((r) => _parseRole(r as Map<String, dynamic>)).toList(),
|
||||
preferences: UserPreferences.fromJson(prefsJson),
|
||||
isNewUser: json['is_new_user'] as bool,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a role from API JSON.
|
||||
Role _parseRole(Map<String, dynamic> json) {
|
||||
final name = json['name'] as String;
|
||||
final domainStr = json['domain'] as String;
|
||||
final category = json['category'] as String? ?? 'general';
|
||||
final actionStr = json['action'] as String;
|
||||
|
||||
final domain = Domain.fromString(domainStr);
|
||||
final action = Action.fromString(actionStr);
|
||||
|
||||
if (domain == null || action == null) {
|
||||
// Return a placeholder role for unknown domains/actions
|
||||
return Role(
|
||||
id: json['id'] as String,
|
||||
name: name,
|
||||
domain: Domain.admin, // Fallback
|
||||
category: category,
|
||||
action: Action.viewer, // Fallback - least privilege
|
||||
);
|
||||
}
|
||||
|
||||
return Role(
|
||||
id: json['id'] as String,
|
||||
name: name,
|
||||
domain: domain,
|
||||
category: category,
|
||||
action: action,
|
||||
);
|
||||
}
|
||||
|
||||
/// Datasource for auth API endpoints.
|
||||
class AuthDatasource {
|
||||
AuthDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Sync user with core-api after OIDC authentication.
|
||||
///
|
||||
/// Sends the OIDC access token to core-api, which validates it with Authentik
|
||||
/// and returns the user profile, roles, and preferences.
|
||||
Future<AuthSyncResponse> syncUser(String accessToken) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'/auth/sync',
|
||||
data: {'access_token': accessToken},
|
||||
);
|
||||
|
||||
return AuthSyncResponse.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// Get current user profile via NPM forward auth.
|
||||
///
|
||||
/// This endpoint reads X-authentik-* headers set by NPM forward auth.
|
||||
/// Returns user profile if authenticated via the proxy.
|
||||
/// Throws 401 if not authenticated or accessing directly.
|
||||
Future<AuthSyncResponse> getCurrentUser() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('/auth/users/me');
|
||||
return AuthSyncResponse.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// Update user preferences.
|
||||
Future<UserPreferences> updatePreferences({
|
||||
String? theme,
|
||||
String? defaultRoom,
|
||||
Map<String, dynamic>? preferencesJson,
|
||||
}) async {
|
||||
final data = <String, dynamic>{};
|
||||
if (theme != null) data['theme'] = theme;
|
||||
if (defaultRoom != null) data['default_room'] = defaultRoom;
|
||||
if (preferencesJson != null) data['preferences_json'] = preferencesJson;
|
||||
|
||||
final response = await _dio.patch<Map<String, dynamic>>(
|
||||
'/auth/users/me/preferences',
|
||||
data: data,
|
||||
);
|
||||
|
||||
return UserPreferences.fromJson(response.data!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for the auth datasource.
|
||||
@riverpod
|
||||
AuthDatasource authDatasource(Ref ref) {
|
||||
return AuthDatasource(ref.watch(coreApiClientProvider));
|
||||
}
|
||||
@@ -1,28 +1,49 @@
|
||||
import 'dart:convert' show jsonDecode, jsonEncode;
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../providers/annotations.dart';
|
||||
import 'auth_datasource.dart';
|
||||
import 'auth_state.dart';
|
||||
import 'oidc_service.dart';
|
||||
import 'oidc_service_web.dart';
|
||||
import 'permissions.dart';
|
||||
import 'user_preferences.dart';
|
||||
import 'web_utils.dart' as web_utils;
|
||||
|
||||
part 'auth_provider.g.dart';
|
||||
|
||||
/// Provides authentication state and operations.
|
||||
///
|
||||
/// Note: Full OIDC implementation with flutter_appauth requires
|
||||
/// native platform configuration. For now, this provides the
|
||||
/// state management infrastructure.
|
||||
@riverpod
|
||||
/// Supports OIDC Authorization Code flow with PKCE on all platforms:
|
||||
/// - **Web**: Browser redirect to Authentik, callback via /callback route
|
||||
/// - **Mobile**: flutter_appauth with custom URL scheme
|
||||
///
|
||||
/// After OIDC authentication, syncs with core-api via POST /auth/sync
|
||||
/// to get user profile, roles, and preferences.
|
||||
@persistentRiverpod
|
||||
class AuthNotifier extends _$AuthNotifier {
|
||||
// Storage keys
|
||||
static const _accessTokenKey = 'auth_access_token';
|
||||
static const _refreshTokenKey = 'auth_refresh_token';
|
||||
static const _expiresAtKey = 'auth_expires_at';
|
||||
static const _userIdKey = 'auth_user_id';
|
||||
static const _authentikIdKey = 'auth_authentik_id';
|
||||
static const _userNameKey = 'auth_user_name';
|
||||
static const _userEmailKey = 'auth_user_email';
|
||||
static const _avatarUrlKey = 'auth_avatar_url';
|
||||
static const _rolesKey = 'auth_roles';
|
||||
static const _preferencesKey = 'auth_preferences';
|
||||
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
// AuthController.initialize() in main() handles OIDC flow before app starts.
|
||||
// By the time we get here, tokens are already stored (or we're in LAN mode).
|
||||
// Just load the stored auth state.
|
||||
return _loadStoredAuth();
|
||||
}
|
||||
|
||||
@@ -40,17 +61,36 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
? DateTime.fromMillisecondsSinceEpoch(expiresAtMs)
|
||||
: null;
|
||||
|
||||
// Load roles from JSON
|
||||
final rolesJson = prefs.getString(_rolesKey);
|
||||
final roles = rolesJson != null ? _parseRoles(rolesJson) : <Role>[];
|
||||
|
||||
// Load preferences from JSON
|
||||
final prefsJson = prefs.getString(_preferencesKey);
|
||||
final preferences = prefsJson != null
|
||||
? UserPreferences.fromJson(jsonDecode(prefsJson) as Map<String, dynamic>)
|
||||
: null;
|
||||
|
||||
final authState = AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: accessToken,
|
||||
refreshToken: prefs.getString(_refreshTokenKey),
|
||||
expiresAt: expiresAt,
|
||||
userId: prefs.getString(_userIdKey),
|
||||
authentikId: prefs.getString(_authentikIdKey),
|
||||
userName: prefs.getString(_userNameKey),
|
||||
userEmail: prefs.getString(_userEmailKey),
|
||||
avatarUrl: prefs.getString(_avatarUrlKey),
|
||||
roles: roles,
|
||||
preferences: preferences,
|
||||
);
|
||||
|
||||
// Check if token is expired
|
||||
// Check if token is expired - try to refresh
|
||||
if (authState.isTokenExpired && authState.refreshToken != null) {
|
||||
developer.log('Token expired, attempting refresh', name: 'auth');
|
||||
return _tryRefreshToken(authState);
|
||||
}
|
||||
|
||||
if (authState.isTokenExpired) {
|
||||
developer.log('Stored token expired, clearing auth', name: 'auth');
|
||||
await _clearStoredAuth();
|
||||
@@ -65,29 +105,285 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign in with OIDC (placeholder for flutter_appauth integration).
|
||||
/// Parse roles from stored JSON.
|
||||
List<Role> _parseRoles(String json) {
|
||||
try {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list.map((item) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final domain = Domain.fromString(map['domain'] as String);
|
||||
final action = Action.fromString(map['action'] as String);
|
||||
|
||||
if (domain == null || action == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Role(
|
||||
id: map['id'] as String,
|
||||
name: map['name'] as String,
|
||||
domain: domain,
|
||||
category: map['category'] as String? ?? 'general',
|
||||
action: action,
|
||||
);
|
||||
}).whereType<Role>().toList();
|
||||
} catch (e) {
|
||||
developer.log('Failed to parse roles: $e', name: 'auth');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to refresh the access token.
|
||||
Future<AuthState> _tryRefreshToken(AuthState currentState) async {
|
||||
if (currentState.refreshToken == null) {
|
||||
await _clearStoredAuth();
|
||||
return const AuthState();
|
||||
}
|
||||
|
||||
try {
|
||||
final oidcService = ref.read(oidcServiceProvider);
|
||||
final tokens = await oidcService.refreshToken(currentState.refreshToken!);
|
||||
|
||||
// Update stored tokens
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_accessTokenKey, tokens.accessToken);
|
||||
if (tokens.refreshToken != null) {
|
||||
await prefs.setString(_refreshTokenKey, tokens.refreshToken!);
|
||||
}
|
||||
await prefs.setInt(_expiresAtKey, tokens.expiresAt.millisecondsSinceEpoch);
|
||||
|
||||
developer.log('Token refreshed successfully', name: 'auth');
|
||||
|
||||
return currentState.copyWith(
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken ?? currentState.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
);
|
||||
} catch (e) {
|
||||
developer.log('Token refresh failed: $e', name: 'auth');
|
||||
await _clearStoredAuth();
|
||||
return const AuthState();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign in with the appropriate method for the platform.
|
||||
///
|
||||
/// - **Web**: Redirects to Authentik for OIDC authentication
|
||||
/// - **Mobile**: Opens Authentik login via OIDC, then syncs with core-api
|
||||
Future<void> signIn() async {
|
||||
// TODO: Implement OIDC flow with flutter_appauth
|
||||
// For now, this is a placeholder that will be implemented
|
||||
// when native platform configuration is complete.
|
||||
developer.log('Sign in requested - OIDC not yet configured', name: 'auth');
|
||||
if (!AppConfig.requiresAuth) {
|
||||
developer.log('Auth not required in LAN mode', name: 'auth');
|
||||
// In LAN mode, set a minimal authenticated state
|
||||
state = const AsyncData(AuthState(isAuthenticated: true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Web: Use OIDC flow with browser redirect
|
||||
if (kIsWeb) {
|
||||
developer.log('Web sign-in: starting OIDC flow', name: 'auth');
|
||||
state = const AsyncLoading();
|
||||
|
||||
try {
|
||||
final oidcService = OidcServiceWeb();
|
||||
final authUrl = await oidcService.getAuthorizationUrl();
|
||||
developer.log('Redirecting to: $authUrl', name: 'auth');
|
||||
web_utils.redirectTo(authUrl);
|
||||
// Browser will redirect, so we don't update state here
|
||||
} catch (e, stack) {
|
||||
developer.log('Failed to start OIDC flow: $e', name: 'auth');
|
||||
state = AsyncError(e, stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile: Use OIDC flow with flutter_appauth
|
||||
state = const AsyncLoading();
|
||||
|
||||
try {
|
||||
// Step 1: OIDC authentication with Authentik
|
||||
developer.log('Starting OIDC authentication', name: 'auth');
|
||||
final oidcService = ref.read(oidcServiceProvider);
|
||||
final tokens = await oidcService.signIn();
|
||||
|
||||
// Step 2: Sync with core-api to get user profile and roles
|
||||
developer.log('Syncing with core-api', name: 'auth');
|
||||
final authDatasource = ref.read(authDatasourceProvider);
|
||||
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
|
||||
|
||||
// Step 3: Store credentials and user data
|
||||
await _storeAuth(
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
userId: syncResponse.userId,
|
||||
authentikId: syncResponse.authentikId,
|
||||
userName: syncResponse.name,
|
||||
userEmail: syncResponse.email,
|
||||
avatarUrl: syncResponse.avatarUrl,
|
||||
roles: syncResponse.roles,
|
||||
preferences: syncResponse.preferences,
|
||||
);
|
||||
|
||||
state = AsyncData(AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
userId: syncResponse.userId,
|
||||
authentikId: syncResponse.authentikId,
|
||||
userName: syncResponse.name,
|
||||
userEmail: syncResponse.email,
|
||||
avatarUrl: syncResponse.avatarUrl,
|
||||
roles: syncResponse.roles,
|
||||
preferences: syncResponse.preferences,
|
||||
));
|
||||
|
||||
developer.log(
|
||||
'Authenticated as ${syncResponse.name} with ${syncResponse.roles.length} roles',
|
||||
name: 'auth',
|
||||
);
|
||||
} on OidcException catch (e) {
|
||||
developer.log('OIDC authentication failed: $e', name: 'auth');
|
||||
state = AsyncError(e, StackTrace.current);
|
||||
} catch (e, stack) {
|
||||
developer.log('Authentication failed: $e', name: 'auth');
|
||||
state = AsyncError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle OIDC callback after Authentik redirects back (web only).
|
||||
///
|
||||
/// [code] is the authorization code from the callback URL.
|
||||
/// [state] is the state parameter for CSRF verification.
|
||||
Future<void> handleOidcCallback(String code, String callbackState) async {
|
||||
if (!kIsWeb) {
|
||||
developer.log('handleOidcCallback called on non-web platform', name: 'auth');
|
||||
return;
|
||||
}
|
||||
|
||||
developer.log('Handling OIDC callback', name: 'auth');
|
||||
state = const AsyncLoading();
|
||||
|
||||
try {
|
||||
// Step 1: Exchange code for tokens
|
||||
final oidcService = OidcServiceWeb();
|
||||
final tokens = await oidcService.exchangeCode(code, callbackState);
|
||||
|
||||
// Step 2: Sync with core-api to get user profile and roles
|
||||
developer.log('Syncing with core-api', name: 'auth');
|
||||
final authDatasource = ref.read(authDatasourceProvider);
|
||||
final syncResponse = await authDatasource.syncUser(tokens.accessToken);
|
||||
|
||||
developer.log(
|
||||
'Synced user: ${syncResponse.name} with ${syncResponse.roles.length} roles',
|
||||
name: 'auth',
|
||||
);
|
||||
|
||||
// Step 3: Store credentials and user data from sync response
|
||||
await _storeAuth(
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
userId: syncResponse.userId,
|
||||
authentikId: syncResponse.authentikId,
|
||||
userName: syncResponse.name,
|
||||
userEmail: syncResponse.email,
|
||||
avatarUrl: syncResponse.avatarUrl,
|
||||
roles: syncResponse.roles,
|
||||
preferences: syncResponse.preferences,
|
||||
);
|
||||
|
||||
state = AsyncData(AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
userId: syncResponse.userId,
|
||||
authentikId: syncResponse.authentikId,
|
||||
userName: syncResponse.name,
|
||||
userEmail: syncResponse.email,
|
||||
avatarUrl: syncResponse.avatarUrl,
|
||||
roles: syncResponse.roles,
|
||||
preferences: syncResponse.preferences,
|
||||
));
|
||||
|
||||
developer.log('Authenticated as ${syncResponse.name}', name: 'auth');
|
||||
|
||||
// Clean up the URL by removing the query parameters
|
||||
web_utils.replaceUrl('/');
|
||||
} on OidcException catch (e) {
|
||||
developer.log('OIDC callback failed: $e', name: 'auth');
|
||||
state = AsyncError(e, StackTrace.current);
|
||||
} catch (e, stack) {
|
||||
developer.log('Callback handling failed: $e', name: 'auth');
|
||||
state = AsyncError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out and clear stored credentials.
|
||||
///
|
||||
/// On web, also redirects to Authentik's logout endpoint to end the SSO session.
|
||||
Future<void> signOut() async {
|
||||
// Clear local storage first
|
||||
await _clearStoredAuth();
|
||||
state = const AsyncData(AuthState());
|
||||
developer.log('Signed out', name: 'auth');
|
||||
developer.log('Signed out locally', name: 'auth');
|
||||
|
||||
// On web, redirect to Authentik logout to end SSO session
|
||||
if (kIsWeb && AppConfig.requiresAuth) {
|
||||
try {
|
||||
final oidcService = OidcServiceWeb();
|
||||
final logoutUrl = await oidcService.getLogoutUrl();
|
||||
developer.log('Redirecting to Authentik logout', name: 'auth');
|
||||
web_utils.redirectTo(logoutUrl);
|
||||
} catch (e) {
|
||||
developer.log('Failed to get logout URL: $e', name: 'auth');
|
||||
// Local logout already done, just reload to trigger re-auth
|
||||
web_utils.redirectTo('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update auth state (called after successful OIDC flow).
|
||||
Future<void> setAuthenticated({
|
||||
/// Update user preferences.
|
||||
Future<void> updatePreferences({
|
||||
String? theme,
|
||||
String? defaultRoom,
|
||||
Map<String, dynamic>? preferencesJson,
|
||||
}) async {
|
||||
final currentState = state.value;
|
||||
if (currentState == null || !currentState.isAuthenticated) return;
|
||||
|
||||
try {
|
||||
final authDatasource = ref.read(authDatasourceProvider);
|
||||
final newPrefs = await authDatasource.updatePreferences(
|
||||
theme: theme,
|
||||
defaultRoom: defaultRoom,
|
||||
preferencesJson: preferencesJson,
|
||||
);
|
||||
|
||||
// Update stored preferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_preferencesKey, jsonEncode(newPrefs.toJson()));
|
||||
|
||||
state = AsyncData(currentState.copyWith(preferences: newPrefs));
|
||||
developer.log('Preferences updated', name: 'auth');
|
||||
} catch (e) {
|
||||
developer.log('Failed to update preferences: $e', name: 'auth');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Store authentication data to SharedPreferences.
|
||||
Future<void> _storeAuth({
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
DateTime? expiresAt,
|
||||
String? userId,
|
||||
String? authentikId,
|
||||
String? userName,
|
||||
String? userEmail,
|
||||
String? avatarUrl,
|
||||
List<Role>? roles,
|
||||
UserPreferences? preferences,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -99,20 +395,27 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
await prefs.setInt(_expiresAtKey, expiresAt.millisecondsSinceEpoch);
|
||||
}
|
||||
if (userId != null) await prefs.setString(_userIdKey, userId);
|
||||
if (authentikId != null) await prefs.setString(_authentikIdKey, authentikId);
|
||||
if (userName != null) await prefs.setString(_userNameKey, userName);
|
||||
if (userEmail != null) await prefs.setString(_userEmailKey, userEmail);
|
||||
if (avatarUrl != null) await prefs.setString(_avatarUrlKey, avatarUrl);
|
||||
|
||||
state = AsyncData(AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: expiresAt,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
userEmail: userEmail,
|
||||
));
|
||||
// Store roles as JSON
|
||||
if (roles != null) {
|
||||
final rolesJson = jsonEncode(roles.map((r) => {
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'domain': r.domain.value,
|
||||
'category': r.category,
|
||||
'action': r.action.name,
|
||||
}).toList());
|
||||
await prefs.setString(_rolesKey, rolesJson);
|
||||
}
|
||||
|
||||
developer.log('Authenticated as $userName', name: 'auth');
|
||||
// Store preferences as JSON
|
||||
if (preferences != null) {
|
||||
await prefs.setString(_preferencesKey, jsonEncode(preferences.toJson()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearStoredAuth() async {
|
||||
@@ -121,7 +424,11 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
await prefs.remove(_refreshTokenKey);
|
||||
await prefs.remove(_expiresAtKey);
|
||||
await prefs.remove(_userIdKey);
|
||||
await prefs.remove(_authentikIdKey);
|
||||
await prefs.remove(_userNameKey);
|
||||
await prefs.remove(_userEmailKey);
|
||||
await prefs.remove(_avatarUrlKey);
|
||||
await prefs.remove(_rolesKey);
|
||||
await prefs.remove(_preferencesKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'permissions.dart';
|
||||
import 'user_preferences.dart';
|
||||
|
||||
part 'auth_state.freezed.dart';
|
||||
|
||||
/// Authentication state.
|
||||
/// Authentication state including user profile, roles, and preferences.
|
||||
@freezed
|
||||
class AuthState with _$AuthState {
|
||||
sealed class AuthState with _$AuthState {
|
||||
const factory AuthState({
|
||||
/// Whether user is authenticated.
|
||||
@Default(false) bool isAuthenticated,
|
||||
|
||||
/// OIDC access token.
|
||||
String? accessToken,
|
||||
|
||||
/// OIDC refresh token.
|
||||
String? refreshToken,
|
||||
|
||||
/// Token expiration time.
|
||||
DateTime? expiresAt,
|
||||
|
||||
/// Internal user ID (from core-api).
|
||||
String? userId,
|
||||
|
||||
/// Authentik user ID.
|
||||
String? authentikId,
|
||||
|
||||
/// User display name.
|
||||
String? userName,
|
||||
|
||||
/// User email address.
|
||||
String? userEmail,
|
||||
|
||||
/// User avatar URL.
|
||||
String? avatarUrl,
|
||||
|
||||
/// User's permission roles.
|
||||
@Default([]) List<Role> roles,
|
||||
|
||||
/// User preferences.
|
||||
UserPreferences? preferences,
|
||||
}) = _AuthState;
|
||||
|
||||
const AuthState._();
|
||||
@@ -23,4 +51,12 @@ class AuthState with _$AuthState {
|
||||
// Consider expired if less than 1 minute remaining
|
||||
return DateTime.now().isAfter(expiresAt!.subtract(const Duration(minutes: 1)));
|
||||
}
|
||||
|
||||
/// Check if user has the specified permission.
|
||||
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
|
||||
return roles.hasPermission(domain, action, category: category);
|
||||
}
|
||||
|
||||
/// Check if user is a global admin.
|
||||
bool get isGlobalAdmin => roles.isGlobalAdmin;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
part 'oidc_service.g.dart';
|
||||
|
||||
/// OIDC token response containing access and refresh tokens.
|
||||
class OidcTokens {
|
||||
const OidcTokens({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresAt,
|
||||
this.idToken,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
final DateTime expiresAt;
|
||||
final String? idToken;
|
||||
}
|
||||
|
||||
/// Service for OIDC authentication using flutter_appauth.
|
||||
///
|
||||
/// Handles the Authorization Code flow with PKCE for secure authentication
|
||||
/// against Authentik.
|
||||
class OidcService {
|
||||
OidcService({FlutterAppAuth? appAuth}) : _appAuth = appAuth ?? const FlutterAppAuth();
|
||||
|
||||
final FlutterAppAuth _appAuth;
|
||||
|
||||
/// OIDC scopes to request.
|
||||
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
|
||||
|
||||
/// Redirect URI for the app.
|
||||
static String get _redirectUri => '${AppConfig.authRedirectScheme}://callback';
|
||||
|
||||
/// Start the authorization code flow.
|
||||
///
|
||||
/// Opens a browser/webview for user to authenticate with Authentik,
|
||||
/// then exchanges the authorization code for tokens.
|
||||
///
|
||||
/// Throws [OidcException] if authentication fails.
|
||||
Future<OidcTokens> signIn() async {
|
||||
try {
|
||||
final result = await _appAuth.authorizeAndExchangeCode(
|
||||
AuthorizationTokenRequest(
|
||||
AppConfig.authClientId,
|
||||
_redirectUri,
|
||||
discoveryUrl: AppConfig.authDiscoveryUrl,
|
||||
scopes: _scopes,
|
||||
),
|
||||
);
|
||||
|
||||
if (result.accessToken == null) {
|
||||
throw OidcException('Authorization failed: no access token');
|
||||
}
|
||||
|
||||
return OidcTokens(
|
||||
accessToken: result.accessToken!,
|
||||
refreshToken: result.refreshToken,
|
||||
expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)),
|
||||
idToken: result.idToken,
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
throw OidcException('Authorization failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the access token using a refresh token.
|
||||
///
|
||||
/// Throws [OidcException] if refresh fails.
|
||||
Future<OidcTokens> refreshToken(String refreshToken) async {
|
||||
try {
|
||||
final result = await _appAuth.token(
|
||||
TokenRequest(
|
||||
AppConfig.authClientId,
|
||||
_redirectUri,
|
||||
discoveryUrl: AppConfig.authDiscoveryUrl,
|
||||
refreshToken: refreshToken,
|
||||
scopes: _scopes,
|
||||
),
|
||||
);
|
||||
|
||||
if (result.accessToken == null) {
|
||||
throw OidcException('Token refresh failed: no access token');
|
||||
}
|
||||
|
||||
return OidcTokens(
|
||||
accessToken: result.accessToken!,
|
||||
refreshToken: result.refreshToken ?? refreshToken,
|
||||
expiresAt: result.accessTokenExpirationDateTime ?? DateTime.now().add(const Duration(hours: 1)),
|
||||
idToken: result.idToken,
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
throw OidcException('Token refresh failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception thrown when OIDC operations fail.
|
||||
class OidcException implements Exception {
|
||||
const OidcException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'OidcException: $message';
|
||||
}
|
||||
|
||||
/// Provider for the OIDC service.
|
||||
@riverpod
|
||||
OidcService oidcService(Ref ref) {
|
||||
return OidcService();
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import 'oidc_service.dart';
|
||||
import 'web_utils.dart' as web_utils;
|
||||
|
||||
/// Web implementation of OIDC service using browser redirect flow.
|
||||
///
|
||||
/// Uses Authorization Code flow with PKCE for secure authentication.
|
||||
/// On web, we can't use flutter_appauth, so we implement the flow manually
|
||||
/// using browser redirects and URL parsing.
|
||||
class OidcServiceWeb implements OidcService {
|
||||
OidcServiceWeb({Dio? dio}) : _dio = dio ?? Dio();
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// OIDC scopes to request.
|
||||
static const _scopes = ['openid', 'profile', 'email', 'offline_access'];
|
||||
|
||||
/// Redirect URI for web.
|
||||
static String get _redirectUri => '${AppConfig.webBaseUrl}/callback';
|
||||
|
||||
// SessionStorage keys for PKCE state (persists across redirect)
|
||||
static const _codeVerifierKey = 'oidc_code_verifier';
|
||||
static const _stateKey = 'oidc_state';
|
||||
|
||||
/// Get the authorization URL to redirect the browser to.
|
||||
///
|
||||
/// Returns a URL that the browser should navigate to for authentication.
|
||||
/// The [codeVerifier] and [state] are stored for later verification.
|
||||
///
|
||||
/// If [silent] is true, adds `prompt=none` to skip login UI.
|
||||
/// This is used when the user already has an Authentik session (via NPM).
|
||||
/// Authentik will instantly redirect back with a code, or return an error
|
||||
/// if there's no valid session.
|
||||
Future<String> getAuthorizationUrl({bool silent = false}) async {
|
||||
// Fetch OIDC discovery document
|
||||
final discovery = await _fetchDiscovery();
|
||||
final authEndpoint = discovery['authorization_endpoint'] as String;
|
||||
|
||||
// Generate PKCE code verifier and challenge
|
||||
final codeVerifier = _generateCodeVerifier();
|
||||
final codeChallenge = _generateCodeChallenge(codeVerifier);
|
||||
|
||||
// Generate state for CSRF protection
|
||||
final state = _generateRandomString(32);
|
||||
|
||||
// Store PKCE state in sessionStorage (persists across redirect)
|
||||
web_utils.setSessionStorage(_codeVerifierKey, codeVerifier);
|
||||
web_utils.setSessionStorage(_stateKey, state);
|
||||
|
||||
// Build authorization URL
|
||||
final params = {
|
||||
'client_id': AppConfig.authClientId,
|
||||
'redirect_uri': _redirectUri,
|
||||
'response_type': 'code',
|
||||
'scope': _scopes.join(' '),
|
||||
'code_challenge': codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'state': state,
|
||||
if (silent) 'prompt': 'none', // Silent auth - no UI, instant redirect
|
||||
};
|
||||
|
||||
final uri = Uri.parse(authEndpoint).replace(queryParameters: params);
|
||||
developer.log('Authorization URL (silent=$silent): $uri', name: 'oidc_web');
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
/// Exchange authorization code for tokens.
|
||||
///
|
||||
/// Call this after the browser redirects back with the authorization code.
|
||||
/// [code] is the authorization code from the callback URL.
|
||||
/// [state] is the state parameter from the callback URL (verified for CSRF).
|
||||
Future<OidcTokens> exchangeCode(String code, String state) async {
|
||||
// Retrieve PKCE state from sessionStorage
|
||||
final storedState = web_utils.getSessionStorage(_stateKey);
|
||||
final codeVerifier = web_utils.getSessionStorage(_codeVerifierKey);
|
||||
|
||||
developer.log('Stored state: $storedState, received state: $state', name: 'oidc_web');
|
||||
developer.log('Code verifier present: ${codeVerifier != null}', name: 'oidc_web');
|
||||
|
||||
// Verify state matches
|
||||
if (storedState == null || state != storedState) {
|
||||
_clearPkceState();
|
||||
throw OidcException('State mismatch - possible CSRF attack');
|
||||
}
|
||||
|
||||
if (codeVerifier == null) {
|
||||
_clearPkceState();
|
||||
throw OidcException('No code verifier - flow not started properly');
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch token endpoint from discovery
|
||||
final discovery = await _fetchDiscovery();
|
||||
final tokenEndpoint = discovery['token_endpoint'] as String;
|
||||
|
||||
developer.log('Exchanging code at: $tokenEndpoint', name: 'oidc_web');
|
||||
|
||||
// Exchange code for tokens
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
tokenEndpoint,
|
||||
data: {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': AppConfig.authClientId,
|
||||
'redirect_uri': _redirectUri,
|
||||
'code': code,
|
||||
'code_verifier': codeVerifier,
|
||||
},
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
),
|
||||
);
|
||||
|
||||
final data = response.data!;
|
||||
developer.log('Token exchange successful', name: 'oidc_web');
|
||||
|
||||
// Clear stored PKCE state
|
||||
_clearPkceState();
|
||||
|
||||
return OidcTokens(
|
||||
accessToken: data['access_token'] as String,
|
||||
refreshToken: data['refresh_token'] as String?,
|
||||
expiresAt: DateTime.now().add(
|
||||
Duration(seconds: data['expires_in'] as int? ?? 3600),
|
||||
),
|
||||
idToken: data['id_token'] as String?,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
developer.log('Token exchange failed: $e', name: 'oidc_web');
|
||||
_clearPkceState();
|
||||
throw OidcException('Token exchange failed: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear PKCE state from sessionStorage.
|
||||
void _clearPkceState() {
|
||||
web_utils.removeSessionStorage(_codeVerifierKey);
|
||||
web_utils.removeSessionStorage(_stateKey);
|
||||
}
|
||||
|
||||
/// Not used on web - use [getAuthorizationUrl] and [exchangeCode] instead.
|
||||
@override
|
||||
Future<OidcTokens> signIn() async {
|
||||
throw OidcException(
|
||||
'signIn() not supported on web. Use getAuthorizationUrl() and exchangeCode() instead.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresh the access token using a refresh token.
|
||||
@override
|
||||
Future<OidcTokens> refreshToken(String refreshToken) async {
|
||||
try {
|
||||
final discovery = await _fetchDiscovery();
|
||||
final tokenEndpoint = discovery['token_endpoint'] as String;
|
||||
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
tokenEndpoint,
|
||||
data: {
|
||||
'grant_type': 'refresh_token',
|
||||
'client_id': AppConfig.authClientId,
|
||||
'refresh_token': refreshToken,
|
||||
},
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
),
|
||||
);
|
||||
|
||||
final data = response.data!;
|
||||
|
||||
return OidcTokens(
|
||||
accessToken: data['access_token'] as String,
|
||||
refreshToken: data['refresh_token'] as String? ?? refreshToken,
|
||||
expiresAt: DateTime.now().add(
|
||||
Duration(seconds: data['expires_in'] as int? ?? 3600),
|
||||
),
|
||||
idToken: data['id_token'] as String?,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw OidcException('Token refresh failed: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch OIDC discovery document.
|
||||
Future<Map<String, dynamic>> _fetchDiscovery() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
AppConfig.authDiscoveryUrl,
|
||||
);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/// Generate a random code verifier for PKCE.
|
||||
String _generateCodeVerifier() {
|
||||
return _generateRandomString(64);
|
||||
}
|
||||
|
||||
/// Generate code challenge from verifier using S256.
|
||||
String _generateCodeChallenge(String verifier) {
|
||||
final bytes = utf8.encode(verifier);
|
||||
final digest = sha256.convert(bytes);
|
||||
return base64Url.encode(digest.bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// Generate a random string of given length.
|
||||
String _generateRandomString(int length) {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
final random = Random.secure();
|
||||
return List.generate(length, (_) => chars[random.nextInt(chars.length)])
|
||||
.join();
|
||||
}
|
||||
|
||||
/// Get the logout URL to redirect the browser to for SSO logout.
|
||||
///
|
||||
/// [idToken] is optional but recommended for logout verification.
|
||||
/// After logout, Authentik redirects back to [postLogoutRedirectUri].
|
||||
Future<String> getLogoutUrl({String? idToken}) async {
|
||||
final discovery = await _fetchDiscovery();
|
||||
final endSessionEndpoint = discovery['end_session_endpoint'] as String?;
|
||||
|
||||
if (endSessionEndpoint == null) {
|
||||
// Fallback: just redirect to home, local state already cleared
|
||||
developer.log('No end_session_endpoint in discovery', name: 'oidc_web');
|
||||
return AppConfig.webBaseUrl;
|
||||
}
|
||||
|
||||
final params = <String, String>{
|
||||
'post_logout_redirect_uri': AppConfig.webBaseUrl,
|
||||
};
|
||||
|
||||
if (idToken != null) {
|
||||
params['id_token_hint'] = idToken;
|
||||
}
|
||||
|
||||
final uri = Uri.parse(endSessionEndpoint).replace(queryParameters: params);
|
||||
developer.log('Logout URL: $uri', name: 'oidc_web');
|
||||
return uri.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/widgets.dart' hide Action;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'auth_provider.dart';
|
||||
import 'permissions.dart';
|
||||
|
||||
/// A widget that conditionally renders its child based on user permissions.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// PermissionGate(
|
||||
/// domain: Domain.controlRoom,
|
||||
/// action: Action.admin,
|
||||
/// child: DeleteButton(),
|
||||
/// fallback: Text('No permission'),
|
||||
/// )
|
||||
/// ```
|
||||
class PermissionGate extends ConsumerWidget {
|
||||
const PermissionGate({
|
||||
super.key,
|
||||
required this.domain,
|
||||
required this.action,
|
||||
this.category = 'general',
|
||||
required this.child,
|
||||
this.fallback,
|
||||
});
|
||||
|
||||
/// The domain required for this permission.
|
||||
final Domain domain;
|
||||
|
||||
/// The action level required (viewer, user, editor, admin).
|
||||
final Action action;
|
||||
|
||||
/// Optional category within the domain (defaults to 'general').
|
||||
final String category;
|
||||
|
||||
/// Widget to show when user has permission.
|
||||
final Widget child;
|
||||
|
||||
/// Widget to show when user lacks permission (defaults to empty).
|
||||
final Widget? fallback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
final hasPermission = authState.maybeWhen(
|
||||
data: (state) => state.hasPermission(domain, action, category: category),
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
if (hasPermission) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return fallback ?? const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that shows its child only if the user is a global admin.
|
||||
class AdminGate extends ConsumerWidget {
|
||||
const AdminGate({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.fallback,
|
||||
});
|
||||
|
||||
/// Widget to show when user is admin.
|
||||
final Widget child;
|
||||
|
||||
/// Widget to show when user is not admin (defaults to empty).
|
||||
final Widget? fallback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
final isAdmin = authState.maybeWhen(
|
||||
data: (state) => state.isGlobalAdmin,
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
if (isAdmin) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return fallback ?? const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension for checking permissions in code.
|
||||
extension PermissionCheck on WidgetRef {
|
||||
/// Check if the current user has a specific permission.
|
||||
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
|
||||
final authState = read(authProvider);
|
||||
return authState.maybeWhen(
|
||||
data: (state) => state.hasPermission(domain, action, category: category),
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if the current user is a global admin.
|
||||
bool get isGlobalAdmin {
|
||||
final authState = read(authProvider);
|
||||
return authState.maybeWhen(
|
||||
data: (state) => state.isGlobalAdmin,
|
||||
orElse: () => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Permission system for role-based access control.
|
||||
//
|
||||
// Roles follow the format: `domain.category:action`
|
||||
// - Domain: Feature area (control-room, media, etc.)
|
||||
// - Category: Sub-area within domain (default: general)
|
||||
// - Action: Permission level (viewer < user < editor < admin)
|
||||
|
||||
/// Permission domains matching feature areas.
|
||||
enum Domain {
|
||||
controlRoom('control-room'),
|
||||
library('library'),
|
||||
media('media'),
|
||||
ai('ai'),
|
||||
housekeeper('housekeeper'),
|
||||
developer('developer'),
|
||||
documents('documents'),
|
||||
gaming('gaming'),
|
||||
admin('admin');
|
||||
|
||||
const Domain(this.value);
|
||||
|
||||
/// The API string value for this domain.
|
||||
final String value;
|
||||
|
||||
/// Parse a domain string from API response.
|
||||
static Domain? fromString(String value) {
|
||||
for (final domain in Domain.values) {
|
||||
if (domain.value == value) return domain;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Permission actions in hierarchical order.
|
||||
///
|
||||
/// Higher actions imply lower ones:
|
||||
/// - admin implies editor, user, viewer
|
||||
/// - editor implies user, viewer
|
||||
/// - user implies viewer
|
||||
enum Action {
|
||||
viewer(1),
|
||||
user(2),
|
||||
editor(3),
|
||||
admin(4);
|
||||
|
||||
const Action(this.level);
|
||||
|
||||
/// Numeric level for comparison (higher = more permissions).
|
||||
final int level;
|
||||
|
||||
/// Check if this action grants at least the required action.
|
||||
bool grants(Action required) => level >= required.level;
|
||||
|
||||
/// Parse an action string from API response.
|
||||
static Action? fromString(String value) {
|
||||
for (final action in Action.values) {
|
||||
if (action.name == value) return action;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// A permission role assigned to a user.
|
||||
///
|
||||
/// Roles are parsed from the API format: `domain.category:action`
|
||||
class Role {
|
||||
const Role({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.domain,
|
||||
required this.category,
|
||||
required this.action,
|
||||
});
|
||||
|
||||
/// Unique role ID.
|
||||
final String id;
|
||||
|
||||
/// Full role name (e.g., "control-room.general:admin").
|
||||
final String name;
|
||||
|
||||
/// Permission domain.
|
||||
final Domain domain;
|
||||
|
||||
/// Permission category (usually "general").
|
||||
final String category;
|
||||
|
||||
/// Permission action level.
|
||||
final Action action;
|
||||
|
||||
/// Check if this role grants access for the given domain and action.
|
||||
///
|
||||
/// Global admin (`admin.general:admin`) grants access to everything.
|
||||
/// Otherwise, domain and category must match, and action level must be sufficient.
|
||||
bool grants(Domain domain, Action action, {String category = 'general'}) {
|
||||
// Global admin override
|
||||
if (this.domain == Domain.admin &&
|
||||
this.category == 'general' &&
|
||||
this.action == Action.admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check domain and category match
|
||||
if (this.domain != domain || this.category != category) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check action hierarchy
|
||||
return this.action.grants(action);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'Role($name)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Role && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// Extension for checking permissions on a list of roles.
|
||||
extension RoleListPermissions on List<Role> {
|
||||
/// Check if any role grants the required permission.
|
||||
bool hasPermission(Domain domain, Action action, {String category = 'general'}) {
|
||||
return any((role) => role.grants(domain, action, category: category));
|
||||
}
|
||||
|
||||
/// Check if any role grants any of the required permissions.
|
||||
bool hasAnyPermission(List<(Domain, Action)> permissions) {
|
||||
return permissions.any((p) => hasPermission(p.$1, p.$2));
|
||||
}
|
||||
|
||||
/// Check if user is a global admin.
|
||||
bool get isGlobalAdmin => hasPermission(Domain.admin, Action.admin);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'user_preferences.freezed.dart';
|
||||
part 'user_preferences.g.dart';
|
||||
|
||||
/// User preferences synced from core-api.
|
||||
@freezed
|
||||
sealed class UserPreferences with _$UserPreferences {
|
||||
const factory UserPreferences({
|
||||
/// Theme preference: system, light, dark
|
||||
@Default('system') String theme,
|
||||
|
||||
/// Default room for housekeeping
|
||||
@Default('front-hall') String defaultRoom,
|
||||
|
||||
/// Extended preferences as JSON
|
||||
@Default({}) Map<String, dynamic> preferencesJson,
|
||||
}) = _UserPreferences;
|
||||
|
||||
factory UserPreferences.fromJson(Map<String, dynamic> json) =>
|
||||
_$UserPreferencesFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// Web utilities with conditional imports.
|
||||
///
|
||||
/// Uses stub implementation on non-web platforms.
|
||||
library;
|
||||
|
||||
export 'web_utils_stub.dart'
|
||||
if (dart.library.js_interop) 'web_utils_web.dart';
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Stub for non-web platforms.
|
||||
library;
|
||||
|
||||
/// Redirect to a URL (no-op on non-web).
|
||||
void redirectTo(String url) {
|
||||
throw UnsupportedError('redirectTo is only supported on web');
|
||||
}
|
||||
|
||||
/// Get current URL (no-op on non-web).
|
||||
String getCurrentUrl() {
|
||||
throw UnsupportedError('getCurrentUrl is only supported on web');
|
||||
}
|
||||
|
||||
/// Replace current URL without navigation (no-op on non-web).
|
||||
void replaceUrl(String url) {
|
||||
throw UnsupportedError('replaceUrl is only supported on web');
|
||||
}
|
||||
|
||||
/// Store a value in sessionStorage (no-op on non-web).
|
||||
void setSessionStorage(String key, String value) {
|
||||
throw UnsupportedError('setSessionStorage is only supported on web');
|
||||
}
|
||||
|
||||
/// Get a value from sessionStorage (no-op on non-web).
|
||||
String? getSessionStorage(String key) {
|
||||
throw UnsupportedError('getSessionStorage is only supported on web');
|
||||
}
|
||||
|
||||
/// Remove a value from sessionStorage (no-op on non-web).
|
||||
void removeSessionStorage(String key) {
|
||||
throw UnsupportedError('removeSessionStorage is only supported on web');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// Web-specific utilities for browser operations.
|
||||
library;
|
||||
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
/// Redirect the browser to a URL.
|
||||
void redirectTo(String url) {
|
||||
web.window.location.href = url;
|
||||
}
|
||||
|
||||
/// Get the current browser URL.
|
||||
String getCurrentUrl() {
|
||||
return web.window.location.href;
|
||||
}
|
||||
|
||||
/// Replace the current URL in history without navigation.
|
||||
void replaceUrl(String url) {
|
||||
web.window.history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
/// Store a value in sessionStorage.
|
||||
void setSessionStorage(String key, String value) {
|
||||
web.window.sessionStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
/// Get a value from sessionStorage.
|
||||
String? getSessionStorage(String key) {
|
||||
return web.window.sessionStorage.getItem(key);
|
||||
}
|
||||
|
||||
/// Remove a value from sessionStorage.
|
||||
void removeSessionStorage(String key) {
|
||||
web.window.sessionStorage.removeItem(key);
|
||||
}
|
||||
@@ -1,16 +1,34 @@
|
||||
/// Application configuration from compile-time environment variables.
|
||||
///
|
||||
/// Set via: flutter build --dart-define=API_URL=https://...
|
||||
/// ## Defaults (public URLs - auth required)
|
||||
/// Default values use the public https://*.schweitz.net domains so browser
|
||||
/// clients work from any machine (NPM fronts them; LAN clients bypass
|
||||
/// Authentik via source-IP rules):
|
||||
/// ```bash
|
||||
/// flutter run -d chrome
|
||||
/// ```
|
||||
///
|
||||
/// ## Development (LAN - no auth required)
|
||||
/// Override with LAN URLs to hit services directly without OIDC:
|
||||
/// ```bash
|
||||
/// flutter run -d chrome \
|
||||
/// --dart-define=CORE_API_URL=http://192.168.86.149:8083 \
|
||||
/// --dart-define=TATLOCK_API_URL=http://192.168.86.149:8000
|
||||
/// ```
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
/// Core API base URL
|
||||
/// - Default: https://api.schweitz.net (requires OIDC)
|
||||
/// - LAN override: http://192.168.86.149:8083 (no auth)
|
||||
static const coreApiUrl = String.fromEnvironment(
|
||||
'CORE_API_URL',
|
||||
defaultValue: 'https://api.schweitz.net',
|
||||
);
|
||||
|
||||
/// Tatlock API base URL
|
||||
/// - Default: https://tatlock.schweitz.net (requires OIDC)
|
||||
/// - LAN override: http://192.168.86.149:8000 (no auth)
|
||||
static const tatlockApiUrl = String.fromEnvironment(
|
||||
'TATLOCK_API_URL',
|
||||
defaultValue: 'https://tatlock.schweitz.net',
|
||||
@@ -29,12 +47,24 @@ class AppConfig {
|
||||
defaultValue: 'tatlock-ui',
|
||||
);
|
||||
|
||||
/// Authentik redirect URI scheme
|
||||
/// Authentik redirect URI scheme (for mobile/native)
|
||||
static const authRedirectScheme = String.fromEnvironment(
|
||||
'AUTH_REDIRECT_SCHEME',
|
||||
defaultValue: 'net.schweitz.tatlock',
|
||||
);
|
||||
|
||||
/// Web app base URL (for OIDC redirect URI on web)
|
||||
static const webBaseUrl = String.fromEnvironment(
|
||||
'WEB_BASE_URL',
|
||||
defaultValue: 'https://home.schweitz.net',
|
||||
);
|
||||
|
||||
/// Whether running in debug mode
|
||||
static const isDebug = bool.fromEnvironment('DEBUG', defaultValue: false);
|
||||
|
||||
/// Whether auth is required (false for LAN development)
|
||||
static bool get requiresAuth =>
|
||||
coreApiUrl.contains('schweitz.net') ||
|
||||
tatlockApiUrl.contains('schweitz.net');
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/// URL strategy with conditional imports for web/non-web platforms.
|
||||
library;
|
||||
|
||||
export 'url_strategy_stub.dart'
|
||||
if (dart.library.js_interop) 'url_strategy_web.dart';
|
||||
@@ -0,0 +1,4 @@
|
||||
/// Stub for non-web platforms - does nothing.
|
||||
void configureUrlStrategy() {
|
||||
// No-op on mobile/desktop
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// Web-specific URL strategy configuration.
|
||||
library;
|
||||
|
||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
|
||||
void configureUrlStrategy() {
|
||||
// Use path-based URLs instead of hash-based (e.g., /login instead of /#/login)
|
||||
// Required for OIDC callback to work properly
|
||||
usePathUrlStrategy();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
/// Riverpod annotation for providers that should persist for the app lifetime.
|
||||
///
|
||||
/// Use this instead of `@riverpod` when:
|
||||
/// - The provider holds app-level state (theme, auth, config)
|
||||
/// - The provider stores a Ref that must remain valid (API clients with interceptors)
|
||||
/// - Disposing would cause flickering or re-initialization issues
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// @persistentRiverpod
|
||||
/// class ThemeNotifier extends _$ThemeNotifier { ... }
|
||||
/// ```
|
||||
const persistentRiverpod = Riverpod(keepAlive: true);
|
||||
@@ -0,0 +1,125 @@
|
||||
/// Semantic identifiers for UI automation and accessibility.
|
||||
///
|
||||
/// These IDs are exposed via Flutter's Semantics tree, making widgets
|
||||
/// discoverable by automation tools (Appium, WebDriver, Puppeteer with
|
||||
/// accessibility enabled).
|
||||
///
|
||||
/// Naming convention: `{area}_{component}_{identifier}`
|
||||
/// - area: room or feature area (e.g., profile, nav, dataGrid)
|
||||
/// - component: widget type (e.g., menu, button, row)
|
||||
/// - identifier: specific item (e.g., light, containers, selectAll)
|
||||
library;
|
||||
|
||||
/// Profile dropdown menu IDs.
|
||||
abstract class ProfileSemantics {
|
||||
static const button = 'profile_button';
|
||||
static const menu = 'profile_menu';
|
||||
static const settings = 'profile_menu_settings';
|
||||
static const themeSystem = 'profile_menu_theme_system';
|
||||
static const themeLight = 'profile_menu_theme_light';
|
||||
static const themeDark = 'profile_menu_theme_dark';
|
||||
static const logout = 'profile_menu_logout';
|
||||
}
|
||||
|
||||
/// Room tab navigation IDs.
|
||||
abstract class RoomTabSemantics {
|
||||
static const frontHall = 'roomTab_frontHall';
|
||||
static const controlRoom = 'roomTab_controlRoom';
|
||||
static const security = 'roomTab_security';
|
||||
static const parlor = 'roomTab_parlor';
|
||||
|
||||
/// Get semantic ID for room index.
|
||||
static String forIndex(int index) => switch (index) {
|
||||
0 => frontHall,
|
||||
1 => controlRoom,
|
||||
2 => security,
|
||||
3 => parlor,
|
||||
_ => 'roomTab_$index',
|
||||
};
|
||||
}
|
||||
|
||||
/// Navigation panel IDs.
|
||||
abstract class NavSemantics {
|
||||
static const panel = 'nav_panel';
|
||||
static const refresh = 'nav_refresh';
|
||||
|
||||
/// Generate ID for a nav item.
|
||||
static String item(String id) => 'nav_item_$id';
|
||||
|
||||
/// Generate ID for a nav section.
|
||||
static String section(String id) => 'nav_section_$id';
|
||||
}
|
||||
|
||||
/// DataGrid component IDs.
|
||||
abstract class DataGridSemantics {
|
||||
static const grid = 'dataGrid';
|
||||
static const search = 'dataGrid_search';
|
||||
static const searchClear = 'dataGrid_search_clear';
|
||||
static const selectAll = 'dataGrid_selectAll';
|
||||
static const loading = 'dataGrid_loading';
|
||||
static const empty = 'dataGrid_empty';
|
||||
static const error = 'dataGrid_error';
|
||||
static const refresh = 'dataGrid_refresh';
|
||||
|
||||
/// Generate ID for a column header.
|
||||
static String header(String columnId) => 'dataGrid_header_$columnId';
|
||||
|
||||
/// Generate ID for a row.
|
||||
static String row(String itemId) => 'dataGrid_row_$itemId';
|
||||
|
||||
/// Generate ID for a row checkbox.
|
||||
static String rowCheckbox(String itemId) => 'dataGrid_row_${itemId}_checkbox';
|
||||
|
||||
/// Generate ID for a row actions menu.
|
||||
static String rowActions(String itemId) => 'dataGrid_row_${itemId}_actions';
|
||||
|
||||
/// Generate ID for a row action.
|
||||
static String rowAction(String itemId, String actionId) =>
|
||||
'dataGrid_row_${itemId}_action_$actionId';
|
||||
|
||||
/// Generate ID for a bulk action button.
|
||||
static String bulkAction(String actionId) => 'dataGrid_bulk_$actionId';
|
||||
|
||||
static const bulkClear = 'dataGrid_bulk_clear';
|
||||
}
|
||||
|
||||
/// Dialog/modal IDs.
|
||||
abstract class DialogSemantics {
|
||||
static const confirm = 'dialog_confirm';
|
||||
static const cancel = 'dialog_cancel';
|
||||
static const close = 'dialog_close';
|
||||
|
||||
/// Generate ID for a named dialog.
|
||||
static String named(String name) => 'dialog_$name';
|
||||
|
||||
/// Generate ID for a dialog action button.
|
||||
static String action(String dialogName, String actionId) =>
|
||||
'dialog_${dialogName}_$actionId';
|
||||
}
|
||||
|
||||
/// Settings page IDs.
|
||||
abstract class SettingsSemantics {
|
||||
static const themeDropdown = 'settings_theme';
|
||||
static const defaultRoomDropdown = 'settings_defaultRoom';
|
||||
}
|
||||
|
||||
/// Filter panel IDs.
|
||||
abstract class FilterPanelSemantics {
|
||||
static const panel = 'filterPanel';
|
||||
static const search = 'filterPanel_search';
|
||||
static const searchClear = 'filterPanel_search_clear';
|
||||
static const refresh = 'filterPanel_refresh';
|
||||
|
||||
/// Generate ID for a filter item.
|
||||
static String item(String id) => 'filterPanel_item_$id';
|
||||
}
|
||||
|
||||
/// Loading/state indicator IDs.
|
||||
abstract class StateSemantics {
|
||||
static const authLoading = 'state_auth_loading';
|
||||
static const authError = 'state_auth_error';
|
||||
static const pageLoading = 'state_page_loading';
|
||||
|
||||
/// Generate ID for a snackbar.
|
||||
static String snackbar(String type) => 'snackbar_$type';
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wraps a widget with semantic information for accessibility and automation.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// SemanticWidget(
|
||||
/// id: ProfileSemantics.button,
|
||||
/// label: 'Open profile menu',
|
||||
/// child: IconButton(...),
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// For buttons, use `button: true`. For other interactive elements,
|
||||
/// set the appropriate semantic properties.
|
||||
class SemanticWidget extends StatelessWidget {
|
||||
const SemanticWidget({
|
||||
super.key,
|
||||
required this.id,
|
||||
required this.child,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.button = false,
|
||||
this.link = false,
|
||||
this.header = false,
|
||||
this.textField = false,
|
||||
this.enabled = true,
|
||||
this.selected,
|
||||
this.checked,
|
||||
this.value,
|
||||
this.excludeSemantics = false,
|
||||
});
|
||||
|
||||
/// Unique identifier for this widget, exposed via [SemanticsProperties.identifier].
|
||||
final String id;
|
||||
|
||||
/// The widget to wrap.
|
||||
final Widget child;
|
||||
|
||||
/// Accessibility label describing the widget.
|
||||
final String? label;
|
||||
|
||||
/// Hint text for screen readers.
|
||||
final String? hint;
|
||||
|
||||
/// Whether this widget represents a button.
|
||||
final bool button;
|
||||
|
||||
/// Whether this widget represents a link.
|
||||
final bool link;
|
||||
|
||||
/// Whether this widget represents a header.
|
||||
final bool header;
|
||||
|
||||
/// Whether this widget represents a text field.
|
||||
final bool textField;
|
||||
|
||||
/// Whether the widget is enabled.
|
||||
final bool enabled;
|
||||
|
||||
/// Whether the widget is selected (for toggle buttons, tabs).
|
||||
final bool? selected;
|
||||
|
||||
/// Whether the widget is checked (for checkboxes).
|
||||
final bool? checked;
|
||||
|
||||
/// Current value (for sliders, progress indicators).
|
||||
final String? value;
|
||||
|
||||
/// Whether to exclude child semantics.
|
||||
final bool excludeSemantics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
identifier: id,
|
||||
label: label,
|
||||
hint: hint,
|
||||
button: button,
|
||||
link: link,
|
||||
header: header,
|
||||
textField: textField,
|
||||
enabled: enabled,
|
||||
selected: selected,
|
||||
checked: checked,
|
||||
value: value,
|
||||
excludeSemantics: excludeSemantics,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to easily wrap any widget with semantic info.
|
||||
extension SemanticExtension on Widget {
|
||||
/// Wraps this widget with a semantic identifier.
|
||||
Widget withSemantics({
|
||||
required String id,
|
||||
String? label,
|
||||
String? hint,
|
||||
bool button = false,
|
||||
bool link = false,
|
||||
bool enabled = true,
|
||||
bool? selected,
|
||||
bool? checked,
|
||||
}) {
|
||||
return SemanticWidget(
|
||||
id: id,
|
||||
label: label,
|
||||
hint: hint,
|
||||
button: button,
|
||||
link: link,
|
||||
enabled: enabled,
|
||||
selected: selected,
|
||||
checked: checked,
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
||||
import 'package:tatlock_ui/core/providers/annotations.dart';
|
||||
|
||||
part 'theme_provider.g.dart';
|
||||
|
||||
@@ -17,13 +21,21 @@ enum ThemeSetting {
|
||||
}
|
||||
|
||||
/// Provider for theme setting state.
|
||||
@riverpod
|
||||
///
|
||||
/// Syncs with API preferences when user is authenticated. On login, the theme
|
||||
/// from API preferences takes precedence over local storage.
|
||||
@persistentRiverpod
|
||||
class ThemeNotifier extends _$ThemeNotifier {
|
||||
static const _prefsKey = 'theme_setting';
|
||||
|
||||
@override
|
||||
ThemeSetting build() {
|
||||
// Load local setting first for immediate UI
|
||||
_loadSavedSetting();
|
||||
|
||||
// Listen for auth state changes to sync from API preferences
|
||||
_syncFromAuthPreferences();
|
||||
|
||||
return ThemeSetting.system;
|
||||
}
|
||||
|
||||
@@ -39,6 +51,36 @@ class ThemeNotifier extends _$ThemeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Listen to auth state and sync theme from API preferences.
|
||||
void _syncFromAuthPreferences() {
|
||||
ref.listen(authProvider, (_, next) {
|
||||
next.whenData((auth) {
|
||||
final apiTheme = auth.preferences?.theme;
|
||||
if (apiTheme != null && apiTheme.isNotEmpty) {
|
||||
try {
|
||||
final themeSetting = ThemeSetting.values.byName(apiTheme);
|
||||
if (themeSetting != state) {
|
||||
developer.log(
|
||||
'Syncing theme from API: $apiTheme',
|
||||
name: 'theme',
|
||||
);
|
||||
state = themeSetting;
|
||||
// Also persist to local storage for offline use
|
||||
_saveToLocalStorage(themeSetting);
|
||||
}
|
||||
} catch (_) {
|
||||
// Invalid theme value from API, keep current
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveToLocalStorage(ThemeSetting setting) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKey, setting.name);
|
||||
}
|
||||
|
||||
/// Update theme setting and persist to storage.
|
||||
Future<void> setSetting(ThemeSetting setting) async {
|
||||
state = setting;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/data/models/container_model.dart';
|
||||
|
||||
part 'containers_datasource.g.dart';
|
||||
|
||||
/// Remote data source for container operations.
|
||||
class ContainersDatasource {
|
||||
ContainersDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Gets all containers from the API.
|
||||
Future<List<ContainerModel>> getContainers({bool all = true}) async {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
'/infrastructure/containers',
|
||||
queryParameters: {'all': all},
|
||||
);
|
||||
|
||||
return response.data!
|
||||
.map((json) => ContainerModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Gets a single container by ID.
|
||||
Future<ContainerModel> getContainer(String id) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/infrastructure/containers/$id',
|
||||
);
|
||||
|
||||
return ContainerModel.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// Performs an action on a container.
|
||||
Future<void> containerAction(String id, String action) async {
|
||||
await _dio.post<void>('/infrastructure/containers/$id/$action');
|
||||
}
|
||||
|
||||
/// Gets container logs.
|
||||
Future<String> getContainerLogs(
|
||||
String id, {
|
||||
int? tail,
|
||||
bool timestamps = false,
|
||||
}) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/infrastructure/containers/$id/logs',
|
||||
queryParameters: {
|
||||
'tail': ?tail,
|
||||
'timestamps': timestamps,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data ?? '';
|
||||
}
|
||||
|
||||
/// Removes a container.
|
||||
Future<void> removeContainer(String id, {bool force = false}) async {
|
||||
await _dio.delete<void>(
|
||||
'/infrastructure/containers/$id',
|
||||
queryParameters: {'force': force},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the containers datasource.
|
||||
@riverpod
|
||||
ContainersDatasource containersDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
return ContainersDatasource(dio);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
|
||||
|
||||
part 'container_model.freezed.dart';
|
||||
part 'container_model.g.dart';
|
||||
|
||||
/// Container data model for API serialization.
|
||||
@freezed
|
||||
sealed class ContainerModel with _$ContainerModel {
|
||||
const factory ContainerModel({
|
||||
@JsonKey(name: 'Id') required String id,
|
||||
@JsonKey(name: 'Names') required List<String> names,
|
||||
@JsonKey(name: 'Image') required String image,
|
||||
@JsonKey(name: 'State') required String state,
|
||||
@JsonKey(name: 'Status') required String status,
|
||||
@JsonKey(name: 'Labels') @Default({}) Map<String, String> labels,
|
||||
@JsonKey(name: 'Ports') @Default([]) List<PortModel> ports,
|
||||
@JsonKey(name: 'Mounts') @Default([]) List<MountModel> mounts,
|
||||
@JsonKey(name: 'NetworkSettings') NetworkSettingsModel? networkSettings,
|
||||
@JsonKey(name: 'Created') int? created,
|
||||
@JsonKey(name: 'SizeRw') int? sizeRw,
|
||||
@JsonKey(name: 'SizeRootFs') int? sizeRootFs,
|
||||
}) = _ContainerModel;
|
||||
|
||||
const ContainerModel._();
|
||||
|
||||
factory ContainerModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ContainerModelFromJson(json);
|
||||
|
||||
/// Converts to domain entity.
|
||||
Container toEntity() {
|
||||
// Extract stack name from labels (Docker Compose convention)
|
||||
final stackName = labels['com.docker.compose.project'];
|
||||
final stackId = stackName; // Use project name as ID for now
|
||||
|
||||
return Container(
|
||||
id: id.substring(0, 12),
|
||||
fullId: id,
|
||||
name: names.isNotEmpty ? names.first.replaceFirst('/', '') : id,
|
||||
image: image,
|
||||
state: _parseState(state),
|
||||
status: status,
|
||||
stackName: stackName,
|
||||
stackId: stackId,
|
||||
ports: ports.map((p) => p.toEntity()).toList(),
|
||||
labels: labels,
|
||||
mounts: mounts.map((m) => m.toEntity()).toList(),
|
||||
networks: networkSettings?.networks.keys.toList() ?? [],
|
||||
createdAt: created != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(created! * 1000)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
ContainerState _parseState(String state) {
|
||||
return switch (state.toLowerCase()) {
|
||||
'created' => ContainerState.created,
|
||||
'running' => ContainerState.running,
|
||||
'paused' => ContainerState.paused,
|
||||
'restarting' => ContainerState.restarting,
|
||||
'removing' => ContainerState.removing,
|
||||
'exited' => ContainerState.exited,
|
||||
'dead' => ContainerState.dead,
|
||||
_ => ContainerState.exited,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Port mapping model.
|
||||
@freezed
|
||||
sealed class PortModel with _$PortModel {
|
||||
const factory PortModel({
|
||||
@JsonKey(name: 'IP') String? ip,
|
||||
@JsonKey(name: 'PrivatePort') required int privatePort,
|
||||
@JsonKey(name: 'PublicPort') int? publicPort,
|
||||
@JsonKey(name: 'Type') @Default('tcp') String type,
|
||||
}) = _PortModel;
|
||||
|
||||
const PortModel._();
|
||||
|
||||
factory PortModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$PortModelFromJson(json);
|
||||
|
||||
PortMapping toEntity() {
|
||||
return PortMapping(
|
||||
hostIp: ip,
|
||||
hostPort: publicPort,
|
||||
containerPort: privatePort,
|
||||
protocol: type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount model.
|
||||
@freezed
|
||||
sealed class MountModel with _$MountModel {
|
||||
const factory MountModel({
|
||||
@JsonKey(name: 'Type') required String type,
|
||||
@JsonKey(name: 'Source') required String source,
|
||||
@JsonKey(name: 'Destination') required String destination,
|
||||
@JsonKey(name: 'Mode') @Default('rw') String mode,
|
||||
@JsonKey(name: 'RW') @Default(true) bool rw,
|
||||
}) = _MountModel;
|
||||
|
||||
const MountModel._();
|
||||
|
||||
factory MountModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$MountModelFromJson(json);
|
||||
|
||||
VolumeMount toEntity() {
|
||||
return VolumeMount(
|
||||
type: type,
|
||||
source: source,
|
||||
destination: destination,
|
||||
mode: rw ? 'rw' : 'ro',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Network settings model.
|
||||
@freezed
|
||||
sealed class NetworkSettingsModel with _$NetworkSettingsModel {
|
||||
const factory NetworkSettingsModel({
|
||||
@JsonKey(name: 'Networks') @Default({}) Map<String, dynamic> networks,
|
||||
}) = _NetworkSettingsModel;
|
||||
|
||||
factory NetworkSettingsModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$NetworkSettingsModelFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/data/datasources/containers_datasource.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/domain/repositories/container_repository.dart';
|
||||
|
||||
part 'container_repository_impl.g.dart';
|
||||
|
||||
/// Implementation of ContainerRepository using remote datasource.
|
||||
class ContainerRepositoryImpl implements ContainerRepository {
|
||||
ContainerRepositoryImpl(this._datasource);
|
||||
|
||||
final ContainersDatasource _datasource;
|
||||
|
||||
@override
|
||||
Future<List<Container>> getContainers() async {
|
||||
final models = await _datasource.getContainers();
|
||||
return models.map((m) => m.toEntity()).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Container>> getContainersByStack(String stackId) async {
|
||||
final containers = await getContainers();
|
||||
return containers.where((c) => c.stackId == stackId).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Container> getContainer(String id) async {
|
||||
final model = await _datasource.getContainer(id);
|
||||
return model.toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startContainer(String id) async {
|
||||
await _datasource.containerAction(id, 'start');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopContainer(String id) async {
|
||||
await _datasource.containerAction(id, 'stop');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restartContainer(String id) async {
|
||||
await _datasource.containerAction(id, 'restart');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pauseContainer(String id) async {
|
||||
await _datasource.containerAction(id, 'pause');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unpauseContainer(String id) async {
|
||||
await _datasource.containerAction(id, 'unpause');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeContainer(String id, {bool force = false}) async {
|
||||
await _datasource.removeContainer(id, force: force);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getContainerLogs(
|
||||
String id, {
|
||||
int? tail,
|
||||
bool timestamps = false,
|
||||
}) async {
|
||||
return _datasource.getContainerLogs(id, tail: tail, timestamps: timestamps);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String> streamContainerLogs(String id, {bool timestamps = false}) {
|
||||
// TODO: Implement WebSocket/SSE streaming
|
||||
throw UnimplementedError('Log streaming not yet implemented');
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the container repository.
|
||||
@riverpod
|
||||
ContainerRepository containerRepository(Ref ref) {
|
||||
final datasource = ref.watch(containersDatasourceProvider);
|
||||
return ContainerRepositoryImpl(datasource);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'container.freezed.dart';
|
||||
|
||||
/// Docker container entity.
|
||||
@freezed
|
||||
sealed class Container with _$Container {
|
||||
const factory Container({
|
||||
/// Container ID (short form).
|
||||
required String id,
|
||||
|
||||
/// Full container ID.
|
||||
required String fullId,
|
||||
|
||||
/// Container name (without leading slash).
|
||||
required String name,
|
||||
|
||||
/// Image name with tag.
|
||||
required String image,
|
||||
|
||||
/// Current container state.
|
||||
required ContainerState state,
|
||||
|
||||
/// Container status string (e.g., "Up 2 hours").
|
||||
required String status,
|
||||
|
||||
/// Stack/project this container belongs to.
|
||||
String? stackName,
|
||||
|
||||
/// Stack ID if part of a stack.
|
||||
String? stackId,
|
||||
|
||||
/// Mapped ports.
|
||||
@Default([]) List<PortMapping> ports,
|
||||
|
||||
/// Environment variables (key-value pairs).
|
||||
@Default({}) Map<String, String> environment,
|
||||
|
||||
/// Container labels.
|
||||
@Default({}) Map<String, String> labels,
|
||||
|
||||
/// Mounted volumes.
|
||||
@Default([]) List<VolumeMount> mounts,
|
||||
|
||||
/// Networks the container is connected to.
|
||||
@Default([]) List<String> networks,
|
||||
|
||||
/// When the container was created.
|
||||
DateTime? createdAt,
|
||||
|
||||
/// When the container was started.
|
||||
DateTime? startedAt,
|
||||
|
||||
/// CPU usage percentage (0-100).
|
||||
double? cpuPercent,
|
||||
|
||||
/// Memory usage in bytes.
|
||||
int? memoryUsage,
|
||||
|
||||
/// Memory limit in bytes.
|
||||
int? memoryLimit,
|
||||
}) = _Container;
|
||||
|
||||
const Container._();
|
||||
|
||||
/// Whether the container is running.
|
||||
bool get isRunning => state == ContainerState.running;
|
||||
|
||||
/// Whether the container can be started.
|
||||
bool get canStart =>
|
||||
state == ContainerState.exited ||
|
||||
state == ContainerState.created ||
|
||||
state == ContainerState.paused;
|
||||
|
||||
/// Whether the container can be stopped.
|
||||
bool get canStop => state == ContainerState.running;
|
||||
|
||||
/// Whether the container can be restarted.
|
||||
bool get canRestart =>
|
||||
state == ContainerState.running || state == ContainerState.exited;
|
||||
|
||||
/// Memory usage as a percentage of the limit.
|
||||
double? get memoryPercent {
|
||||
if (memoryUsage == null || memoryLimit == null || memoryLimit == 0) {
|
||||
return null;
|
||||
}
|
||||
return (memoryUsage! / memoryLimit!) * 100;
|
||||
}
|
||||
|
||||
/// Formatted memory usage string.
|
||||
String get memoryFormatted {
|
||||
if (memoryUsage == null) return '--';
|
||||
return _formatBytes(memoryUsage!);
|
||||
}
|
||||
|
||||
/// Formatted memory limit string.
|
||||
String get memoryLimitFormatted {
|
||||
if (memoryLimit == null) return '--';
|
||||
return _formatBytes(memoryLimit!);
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
}
|
||||
|
||||
/// Container state.
|
||||
enum ContainerState {
|
||||
created,
|
||||
running,
|
||||
paused,
|
||||
restarting,
|
||||
removing,
|
||||
exited,
|
||||
dead,
|
||||
}
|
||||
|
||||
/// Port mapping configuration.
|
||||
@freezed
|
||||
sealed class PortMapping with _$PortMapping {
|
||||
const factory PortMapping({
|
||||
/// Host IP (usually 0.0.0.0).
|
||||
String? hostIp,
|
||||
|
||||
/// Port on the host.
|
||||
int? hostPort,
|
||||
|
||||
/// Port inside the container.
|
||||
required int containerPort,
|
||||
|
||||
/// Protocol (tcp/udp).
|
||||
@Default('tcp') String protocol,
|
||||
}) = _PortMapping;
|
||||
|
||||
const PortMapping._();
|
||||
|
||||
/// Formatted string representation.
|
||||
String get formatted {
|
||||
if (hostPort == null) return '$containerPort/$protocol';
|
||||
final ip = hostIp == '0.0.0.0' ? '' : '$hostIp:';
|
||||
return '$ip$hostPort->$containerPort/$protocol';
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume mount configuration.
|
||||
@freezed
|
||||
sealed class VolumeMount with _$VolumeMount {
|
||||
const factory VolumeMount({
|
||||
/// Mount type (bind, volume, tmpfs).
|
||||
required String type,
|
||||
|
||||
/// Source path or volume name.
|
||||
required String source,
|
||||
|
||||
/// Destination path in container.
|
||||
required String destination,
|
||||
|
||||
/// Mount mode (rw, ro).
|
||||
@Default('rw') String mode,
|
||||
}) = _VolumeMount;
|
||||
|
||||
const VolumeMount._();
|
||||
|
||||
/// Whether the mount is read-only.
|
||||
bool get isReadOnly => mode == 'ro';
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
|
||||
|
||||
/// Repository interface for container operations.
|
||||
abstract class ContainerRepository {
|
||||
/// Gets all containers.
|
||||
Future<List<Container>> getContainers();
|
||||
|
||||
/// Gets containers filtered by stack.
|
||||
Future<List<Container>> getContainersByStack(String stackId);
|
||||
|
||||
/// Gets a single container by ID.
|
||||
Future<Container> getContainer(String id);
|
||||
|
||||
/// Starts a container.
|
||||
Future<void> startContainer(String id);
|
||||
|
||||
/// Stops a container.
|
||||
Future<void> stopContainer(String id);
|
||||
|
||||
/// Restarts a container.
|
||||
Future<void> restartContainer(String id);
|
||||
|
||||
/// Pauses a container.
|
||||
Future<void> pauseContainer(String id);
|
||||
|
||||
/// Unpauses a container.
|
||||
Future<void> unpauseContainer(String id);
|
||||
|
||||
/// Removes a container.
|
||||
Future<void> removeContainer(String id, {bool force = false});
|
||||
|
||||
/// Gets container logs.
|
||||
Future<String> getContainerLogs(
|
||||
String id, {
|
||||
int? tail,
|
||||
bool timestamps = false,
|
||||
});
|
||||
|
||||
/// Streams container logs in real-time.
|
||||
Stream<String> streamContainerLogs(
|
||||
String id, {
|
||||
bool timestamps = false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart' hide Container;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/providers/containers_provider.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_logs_viewer.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.dart';
|
||||
import 'package:tatlock_ui/routing/url_state.dart';
|
||||
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
|
||||
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
|
||||
|
||||
/// Health status of a container.
|
||||
enum ContainerHealth { healthy, unhealthy, starting, none }
|
||||
|
||||
/// Container data class for DataGrid display.
|
||||
class ContainerData {
|
||||
ContainerData({
|
||||
required this.id,
|
||||
required this.fullId,
|
||||
required this.name,
|
||||
required this.image,
|
||||
required this.state,
|
||||
required this.status,
|
||||
required this.ports,
|
||||
required this.health,
|
||||
});
|
||||
|
||||
factory ContainerData.fromJson(Map<String, dynamic> json) {
|
||||
// Docker API uses capitalized keys
|
||||
final ports = (json['Ports'] as List<dynamic>?)
|
||||
?.map((p) => ContainerPort.fromJson(p as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final fullId = json['Id'] as String? ?? '';
|
||||
final names = json['Names'] as List<dynamic>? ?? [];
|
||||
final name = names.isNotEmpty
|
||||
? (names.first as String).replaceFirst('/', '')
|
||||
: 'Unknown';
|
||||
|
||||
final status = json['Status'] as String? ?? 'Unknown';
|
||||
|
||||
return ContainerData(
|
||||
id: fullId.length > 12 ? fullId.substring(0, 12) : fullId,
|
||||
fullId: fullId,
|
||||
name: name,
|
||||
image: json['Image'] as String? ?? 'Unknown',
|
||||
state: json['State'] as String? ?? 'unknown',
|
||||
status: status,
|
||||
ports: ports,
|
||||
health: _parseHealth(status),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse health status from Docker status string (e.g., "Up 2 hours (healthy)")
|
||||
static ContainerHealth _parseHealth(String status) {
|
||||
final lower = status.toLowerCase();
|
||||
if (lower.contains('(healthy)')) return ContainerHealth.healthy;
|
||||
if (lower.contains('(unhealthy)')) return ContainerHealth.unhealthy;
|
||||
if (lower.contains('(health: starting)')) return ContainerHealth.starting;
|
||||
return ContainerHealth.none;
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String fullId;
|
||||
final String name;
|
||||
final String image;
|
||||
final String state;
|
||||
final String status;
|
||||
final List<ContainerPort> ports;
|
||||
final ContainerHealth health;
|
||||
|
||||
/// Status string with health info stripped (just shows uptime).
|
||||
String get displayStatus {
|
||||
return status
|
||||
.replaceAll(RegExp(r'\s*\(healthy\)', caseSensitive: false), '')
|
||||
.replaceAll(RegExp(r'\s*\(unhealthy\)', caseSensitive: false), '')
|
||||
.replaceAll(RegExp(r'\s*\(health: starting\)', caseSensitive: false), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
bool get canStart => state == 'exited' || state == 'created';
|
||||
bool get canStop => state == 'running';
|
||||
bool get canRestart => state == 'running';
|
||||
}
|
||||
|
||||
class ContainerPort {
|
||||
ContainerPort({required this.privatePort, this.publicPort, this.type = 'tcp'});
|
||||
|
||||
factory ContainerPort.fromJson(Map<String, dynamic> json) => ContainerPort(
|
||||
// Docker API uses PrivatePort/PublicPort
|
||||
privatePort: json['PrivatePort'] as int? ?? 0,
|
||||
publicPort: json['PublicPort'] as int?,
|
||||
type: json['Type'] as String? ?? 'tcp',
|
||||
);
|
||||
|
||||
final int privatePort;
|
||||
final int? publicPort;
|
||||
final String type;
|
||||
|
||||
String get formatted => publicPort != null ? '$publicPort:$privatePort' : '$privatePort';
|
||||
}
|
||||
|
||||
/// Page displaying the list of containers using DataGrid.
|
||||
class ContainersListPage extends ConsumerStatefulWidget {
|
||||
const ContainersListPage({super.key, this.routerState});
|
||||
|
||||
/// Router state for URL deep-linking.
|
||||
final GoRouterState? routerState;
|
||||
|
||||
@override
|
||||
ConsumerState<ContainersListPage> createState() => _ContainersListPageState();
|
||||
}
|
||||
|
||||
class _ContainersListPageState extends ConsumerState<ContainersListPage> {
|
||||
late final StateNotifierProvider<DataGridController<ContainerData>,
|
||||
DataGridState<ContainerData>> _gridProvider;
|
||||
late PageUrlState _urlState;
|
||||
Timer? _urlSyncTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Parse URL state
|
||||
_urlState = PageUrlState.fromQueryParams(
|
||||
widget.routerState?.uri.queryParameters ?? {},
|
||||
);
|
||||
|
||||
final dio = ref.read(coreApiClientProvider);
|
||||
final source = CoreApiDataSource<ContainerData>(
|
||||
dio: dio,
|
||||
endpoint: '/infrastructure/containers',
|
||||
fromJson: ContainerData.fromJson,
|
||||
);
|
||||
|
||||
// Initialize grid with URL state
|
||||
_gridProvider = dataGridProvider<ContainerData>(
|
||||
source: source,
|
||||
config: _buildConfig(),
|
||||
idSelector: (c) => c.id,
|
||||
initialSearch: _urlState.search,
|
||||
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
|
||||
initialSortDescending: _urlState.sortDescending,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlSyncTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Find column index by column ID.
|
||||
int? _columnIndexForId(String? columnId) {
|
||||
if (columnId == null) return null;
|
||||
final columns = _buildConfig().columns;
|
||||
for (var i = 0; i < columns.length; i++) {
|
||||
if (columns[i].id == columnId) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get column ID by index.
|
||||
String? _columnIdForIndex(int index) {
|
||||
final columns = _buildConfig().columns;
|
||||
if (index >= 0 && index < columns.length) {
|
||||
return columns[index].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Schedule URL sync with debounce.
|
||||
void _scheduleUrlSync() {
|
||||
_urlSyncTimer?.cancel();
|
||||
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
|
||||
}
|
||||
|
||||
/// Sync current state to URL.
|
||||
void _syncUrlParams() {
|
||||
final state = ref.read(_gridProvider);
|
||||
|
||||
final params = PageUrlState(
|
||||
search: state.searchQuery.isEmpty ? null : state.searchQuery,
|
||||
sortColumn: state.sortColumnIndex != null
|
||||
? _columnIdForIndex(state.sortColumnIndex!)
|
||||
: null,
|
||||
sortDescending: state.sortDescending,
|
||||
).toQueryParams();
|
||||
|
||||
updateBrowserUrlParams(params);
|
||||
}
|
||||
|
||||
DataGridConfig<ContainerData> _buildConfig() {
|
||||
return DataGridConfig<ContainerData>(
|
||||
columns: [
|
||||
DataGridColumn<ContainerData>(
|
||||
id: 'container',
|
||||
header: 'Container',
|
||||
valueBuilder: (c) => '${c.name} ${c.image}',
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: const DataGridColumnWidth.flex(2),
|
||||
cellBuilder: (context, c) => _ContainerCell(container: c),
|
||||
),
|
||||
DataGridColumn<ContainerData>(
|
||||
id: 'ports',
|
||||
header: 'Ports',
|
||||
valueBuilder: (c) => c.ports.map((p) => p.formatted).join(', '),
|
||||
width: const DataGridColumnWidth.flex(1),
|
||||
cellBuilder: (context, c) => _PortsCell(ports: c.ports),
|
||||
),
|
||||
DataGridColumn<ContainerData>(
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
valueBuilder: (c) => c.displayStatus,
|
||||
width: const DataGridColumnWidth.fixed(160),
|
||||
alignment: DataGridColumnAlignment.end,
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
DataGridAction<ContainerData>(
|
||||
icon: Icons.play_arrow,
|
||||
label: 'Start',
|
||||
onTap: (c) async => _handleAction(c, 'start'),
|
||||
showWhen: (c) => c.canStart,
|
||||
),
|
||||
DataGridAction<ContainerData>(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
onTap: (c) async => _handleAction(c, 'stop'),
|
||||
showWhen: (c) => c.canStop,
|
||||
),
|
||||
DataGridAction<ContainerData>(
|
||||
icon: Icons.refresh,
|
||||
label: 'Restart',
|
||||
onTap: (c) async => _handleAction(c, 'restart'),
|
||||
showWhen: (c) => c.canRestart,
|
||||
),
|
||||
DataGridAction<ContainerData>(
|
||||
icon: Icons.article,
|
||||
label: 'View Logs',
|
||||
onTap: (c) async => _showLogs(c),
|
||||
),
|
||||
],
|
||||
enableSearch: true,
|
||||
searchHint: 'Search containers...',
|
||||
showHeader: true,
|
||||
showFooter: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleAction(ContainerData container, String action) async {
|
||||
final actions = ref.read(containerActionsProvider.notifier);
|
||||
switch (action) {
|
||||
case 'start':
|
||||
await actions.start(container.fullId);
|
||||
case 'stop':
|
||||
await actions.stop(container.fullId);
|
||||
case 'restart':
|
||||
await actions.restart(container.fullId);
|
||||
}
|
||||
// Refresh the grid after action
|
||||
ref.read(_gridProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
Future<void> _showLogs(ContainerData container) async {
|
||||
if (!mounted) return;
|
||||
showContainerLogs(
|
||||
context,
|
||||
containerId: container.fullId,
|
||||
containerName: container.name,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Listen for grid state changes to sync URL
|
||||
ref.listen(_gridProvider, (previous, next) {
|
||||
if (previous?.searchQuery != next.searchQuery ||
|
||||
previous?.sortColumnIndex != next.sortColumnIndex ||
|
||||
previous?.sortDescending != next.sortDescending) {
|
||||
_scheduleUrlSync();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for container action results to show snackbars
|
||||
ref.listen<AsyncValue<void>>(containerActionsProvider, (previous, next) {
|
||||
if (previous?.isLoading == true && !next.isLoading) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Container action completed'),
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Container action failed: $error',
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return DataGrid<ContainerData>(
|
||||
provider: _gridProvider,
|
||||
config: _buildConfig(),
|
||||
idSelector: (c) => c.id,
|
||||
toolbarActions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => ref.read(_gridProvider.notifier).refresh(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContainerCell extends StatelessWidget {
|
||||
const _ContainerCell({required this.container});
|
||||
|
||||
final ContainerData container;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
ContainerStatusBadge.fromString(container.state, health: container.health),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
container.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
container.image,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PortsCell extends StatelessWidget {
|
||||
const _PortsCell({required this.ports});
|
||||
|
||||
final List<ContainerPort> ports;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (ports.isEmpty) {
|
||||
return Text(
|
||||
'-',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Text(
|
||||
ports.map((p) => p.formatted).join(', '),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/data/repositories/container_repository_impl.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
|
||||
|
||||
part 'containers_provider.g.dart';
|
||||
|
||||
/// Provides all containers.
|
||||
@riverpod
|
||||
Future<List<Container>> allContainers(Ref ref) async {
|
||||
final repository = ref.watch(containerRepositoryProvider);
|
||||
return repository.getContainers();
|
||||
}
|
||||
|
||||
/// Provides containers filtered by the selected stack.
|
||||
@riverpod
|
||||
Future<List<Container>> containers(Ref ref) async {
|
||||
final repository = ref.watch(containerRepositoryProvider);
|
||||
final selectedStack = ref.watch(selectedStackProvider);
|
||||
|
||||
if (selectedStack == null) {
|
||||
return repository.getContainers();
|
||||
}
|
||||
|
||||
return repository.getContainersByStack(selectedStack);
|
||||
}
|
||||
|
||||
/// Provides a single container by ID.
|
||||
@riverpod
|
||||
Future<Container> container(Ref ref, String id) async {
|
||||
final repository = ref.watch(containerRepositoryProvider);
|
||||
return repository.getContainer(id);
|
||||
}
|
||||
|
||||
/// Controller for container actions.
|
||||
@riverpod
|
||||
class ContainerActions extends _$ContainerActions {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncValue.data(null);
|
||||
|
||||
Future<void> start(String id) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(containerRepositoryProvider);
|
||||
await repository.startContainer(id);
|
||||
ref.invalidate(allContainersProvider);
|
||||
ref.invalidate(containersProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stop(String id) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(containerRepositoryProvider);
|
||||
await repository.stopContainer(id);
|
||||
ref.invalidate(allContainersProvider);
|
||||
ref.invalidate(containersProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> restart(String id) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(containerRepositoryProvider);
|
||||
await repository.restartContainer(id);
|
||||
ref.invalidate(allContainersProvider);
|
||||
ref.invalidate(containersProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> remove(String id, {bool force = false}) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(containerRepositoryProvider);
|
||||
await repository.removeContainer(id, force: force);
|
||||
ref.invalidate(allContainersProvider);
|
||||
ref.invalidate(containersProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Container logs provider.
|
||||
@riverpod
|
||||
Future<String> containerLogs(
|
||||
Ref ref,
|
||||
String id, {
|
||||
int? tail = 100,
|
||||
}) async {
|
||||
final repository = ref.watch(containerRepositoryProvider);
|
||||
return repository.getContainerLogs(id, tail: tail);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/providers/containers_provider.dart';
|
||||
|
||||
/// Widget for displaying container logs.
|
||||
class ContainerLogsViewer extends ConsumerStatefulWidget {
|
||||
const ContainerLogsViewer({
|
||||
super.key,
|
||||
required this.containerId,
|
||||
required this.containerName,
|
||||
});
|
||||
|
||||
final String containerId;
|
||||
final String containerName;
|
||||
|
||||
@override
|
||||
ConsumerState<ContainerLogsViewer> createState() =>
|
||||
_ContainerLogsViewerState();
|
||||
}
|
||||
|
||||
class _ContainerLogsViewerState extends ConsumerState<ContainerLogsViewer> {
|
||||
final _scrollController = ScrollController();
|
||||
int _tailLines = 100;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final logsAsync = ref.watch(
|
||||
containerLogsProvider(widget.containerId, tail: _tailLines),
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Toolbar
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.containerName,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Tail lines selector
|
||||
DropdownButton<int>(
|
||||
value: _tailLines,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 50, child: Text('50 lines')),
|
||||
DropdownMenuItem(value: 100, child: Text('100 lines')),
|
||||
DropdownMenuItem(value: 500, child: Text('500 lines')),
|
||||
DropdownMenuItem(value: 1000, child: Text('1000 lines')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _tailLines = value);
|
||||
}
|
||||
},
|
||||
underline: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: 'Copy logs',
|
||||
onPressed: logsAsync.whenOrNull(
|
||||
data: (logs) => () async {
|
||||
await Clipboard.setData(ClipboardData(text: logs));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Logs copied to clipboard')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () {
|
||||
ref.invalidate(
|
||||
containerLogsProvider(widget.containerId, tail: _tailLines),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Logs content
|
||||
Expanded(
|
||||
child: logsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text('Failed to load logs'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (logs) => logs.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No logs available',
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black87,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(
|
||||
logs,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows container logs in a bottom sheet.
|
||||
void showContainerLogs(
|
||||
BuildContext context, {
|
||||
required String containerId,
|
||||
required String containerName,
|
||||
}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) => ContainerLogsViewer(
|
||||
containerId: containerId,
|
||||
containerName: containerName,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart' hide Container;
|
||||
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart';
|
||||
|
||||
/// Status badge for container state with optional health indication.
|
||||
class ContainerStatusBadge extends StatelessWidget {
|
||||
const ContainerStatusBadge({
|
||||
super.key,
|
||||
required this.state,
|
||||
this.health,
|
||||
this.showLabel = true,
|
||||
});
|
||||
|
||||
/// Create badge from a string state value.
|
||||
factory ContainerStatusBadge.fromString(
|
||||
String state, {
|
||||
ContainerHealth? health,
|
||||
bool showLabel = true,
|
||||
}) {
|
||||
return ContainerStatusBadge(
|
||||
state: _parseState(state),
|
||||
health: health,
|
||||
showLabel: showLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create badge from state enum and status string (parses health from status).
|
||||
factory ContainerStatusBadge.withStatus({
|
||||
required ContainerState state,
|
||||
required String status,
|
||||
bool showLabel = true,
|
||||
}) {
|
||||
return ContainerStatusBadge(
|
||||
state: state,
|
||||
health: _parseHealthFromStatus(status),
|
||||
showLabel: showLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse health status from Docker status string.
|
||||
static ContainerHealth _parseHealthFromStatus(String status) {
|
||||
final lower = status.toLowerCase();
|
||||
if (lower.contains('(healthy)')) return ContainerHealth.healthy;
|
||||
if (lower.contains('(unhealthy)')) return ContainerHealth.unhealthy;
|
||||
if (lower.contains('(health: starting)')) return ContainerHealth.starting;
|
||||
return ContainerHealth.none;
|
||||
}
|
||||
|
||||
final ContainerState state;
|
||||
final ContainerHealth? health;
|
||||
final bool showLabel;
|
||||
|
||||
static ContainerState _parseState(String value) {
|
||||
return ContainerState.values.firstWhere(
|
||||
(s) => s.name == value.toLowerCase(),
|
||||
orElse: () => ContainerState.exited,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, icon, label) = _getStateStyle(context);
|
||||
|
||||
Widget badge = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
if (showLabel) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (showLabel) {
|
||||
badge = ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 90),
|
||||
child: badge,
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
|
||||
(Color, IconData, String) _getStateStyle(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return switch (state) {
|
||||
ContainerState.running => _getRunningStyle(colorScheme),
|
||||
ContainerState.paused => (
|
||||
Colors.orange,
|
||||
Icons.pause_circle,
|
||||
'Paused',
|
||||
),
|
||||
ContainerState.restarting => (
|
||||
Colors.blue,
|
||||
Icons.refresh,
|
||||
'Restarting',
|
||||
),
|
||||
ContainerState.exited => (
|
||||
colorScheme.outline,
|
||||
Icons.stop_circle,
|
||||
'Exited',
|
||||
),
|
||||
ContainerState.created => (
|
||||
colorScheme.outline,
|
||||
Icons.circle_outlined,
|
||||
'Created',
|
||||
),
|
||||
ContainerState.removing => (
|
||||
Colors.red,
|
||||
Icons.delete,
|
||||
'Removing',
|
||||
),
|
||||
ContainerState.dead => (
|
||||
colorScheme.error,
|
||||
Icons.error,
|
||||
'Dead',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Get style for running state, factoring in health status.
|
||||
(Color, IconData, String) _getRunningStyle(ColorScheme colorScheme) {
|
||||
return switch (health) {
|
||||
ContainerHealth.healthy => (
|
||||
Colors.green,
|
||||
Icons.play_circle,
|
||||
'Running',
|
||||
),
|
||||
ContainerHealth.unhealthy => (
|
||||
Colors.orange,
|
||||
Icons.warning_amber_rounded,
|
||||
'Unhealthy',
|
||||
),
|
||||
ContainerHealth.starting => (
|
||||
Colors.blue,
|
||||
Icons.hourglass_top,
|
||||
'Starting',
|
||||
),
|
||||
ContainerHealth.none || null => (
|
||||
Colors.green,
|
||||
Icons.play_circle,
|
||||
'Running',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart';
|
||||
|
||||
part 'proxy_hosts_datasource.g.dart';
|
||||
|
||||
/// Remote data source for NPM proxy host operations via Core API.
|
||||
class ProxyHostsDatasource {
|
||||
ProxyHostsDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Gets all configured domains from Core API.
|
||||
Future<List<DomainInfoModel>> getDomains() async {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
'/infrastructure/domains',
|
||||
);
|
||||
|
||||
return response.data!
|
||||
.map((json) => DomainInfoModel.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Gets detailed proxy host configuration by ID.
|
||||
Future<ProxyHostModel> getProxyHost(int proxyId) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/infrastructure/proxy/$proxyId',
|
||||
);
|
||||
|
||||
return ProxyHostModel.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// Creates a new proxy host.
|
||||
Future<void> createProxyHost({
|
||||
required List<String> domainNames,
|
||||
required String forwardScheme,
|
||||
required String forwardHost,
|
||||
required int forwardPort,
|
||||
bool sslEnabled = false,
|
||||
}) async {
|
||||
await _dio.post<void>(
|
||||
'/infrastructure/proxy',
|
||||
data: {
|
||||
'domain_names': domainNames,
|
||||
'forward_scheme': forwardScheme,
|
||||
'forward_host': forwardHost,
|
||||
'forward_port': forwardPort,
|
||||
'ssl_enabled': sslEnabled,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates an existing proxy host.
|
||||
Future<void> updateProxyHost(int proxyId, Map<String, dynamic> config) async {
|
||||
await _dio.put<void>(
|
||||
'/infrastructure/proxy/$proxyId',
|
||||
data: config,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the proxy hosts datasource.
|
||||
@riverpod
|
||||
ProxyHostsDatasource proxyHostsDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
return ProxyHostsDatasource(dio);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
|
||||
|
||||
part 'proxy_host_model.freezed.dart';
|
||||
part 'proxy_host_model.g.dart';
|
||||
|
||||
/// Converts API values that can be either int or bool (false) to int?.
|
||||
/// NPM returns false instead of null for missing IDs.
|
||||
class NullableIntOrBoolConverter implements JsonConverter<int?, dynamic> {
|
||||
const NullableIntOrBoolConverter();
|
||||
|
||||
@override
|
||||
int? fromJson(dynamic json) {
|
||||
if (json == null || json == false) return null;
|
||||
if (json is int) return json;
|
||||
if (json is num) return json.toInt();
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic toJson(int? object) => object;
|
||||
}
|
||||
|
||||
/// Converts API values that can be either int or bool to int.
|
||||
/// NPM returns true/false for some 0/1 fields.
|
||||
class IntOrBoolConverter implements JsonConverter<int, dynamic> {
|
||||
const IntOrBoolConverter();
|
||||
|
||||
@override
|
||||
int fromJson(dynamic json) {
|
||||
if (json == null) return 0;
|
||||
if (json is bool) return json ? 1 : 0;
|
||||
if (json is int) return json;
|
||||
if (json is num) return json.toInt();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic toJson(int object) => object;
|
||||
}
|
||||
|
||||
/// Proxy host data model for API serialization.
|
||||
///
|
||||
/// Maps to the NPM API response format via Core API.
|
||||
@freezed
|
||||
sealed class ProxyHostModel with _$ProxyHostModel {
|
||||
const factory ProxyHostModel({
|
||||
required int id,
|
||||
@JsonKey(name: 'domain_names') required List<String> domainNames,
|
||||
@JsonKey(name: 'forward_scheme') required String forwardScheme,
|
||||
@JsonKey(name: 'forward_host') required String forwardHost,
|
||||
@JsonKey(name: 'forward_port') required int forwardPort,
|
||||
@JsonKey(name: 'ssl_forced') @Default(false) bool sslForced,
|
||||
@NullableIntOrBoolConverter() @JsonKey(name: 'certificate_id') int? certificateId,
|
||||
@IntOrBoolConverter() @Default(1) int enabled,
|
||||
@IntOrBoolConverter() @JsonKey(name: 'http2_support') @Default(0) int http2Support,
|
||||
@IntOrBoolConverter() @JsonKey(name: 'hsts_enabled') @Default(0) int hstsEnabled,
|
||||
@NullableIntOrBoolConverter() @JsonKey(name: 'access_list_id') int? accessListId,
|
||||
@IntOrBoolConverter() @JsonKey(name: 'caching_enabled') @Default(0) int cachingEnabled,
|
||||
@IntOrBoolConverter() @JsonKey(name: 'block_exploits') @Default(0) int blockExploits,
|
||||
@IntOrBoolConverter() @JsonKey(name: 'allow_websocket_upgrade') @Default(0) int allowWebsocketUpgrade,
|
||||
@JsonKey(name: 'created_on') String? createdOn,
|
||||
@JsonKey(name: 'modified_on') String? modifiedOn,
|
||||
@Default([]) List<ProxyLocationModel> locations,
|
||||
}) = _ProxyHostModel;
|
||||
|
||||
const ProxyHostModel._();
|
||||
|
||||
factory ProxyHostModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProxyHostModelFromJson(json);
|
||||
|
||||
/// Converts to domain entity.
|
||||
ProxyHost toEntity() {
|
||||
return ProxyHost(
|
||||
id: id,
|
||||
domainNames: domainNames,
|
||||
forwardScheme: forwardScheme,
|
||||
forwardHost: forwardHost,
|
||||
forwardPort: forwardPort,
|
||||
sslEnabled: certificateId != null && certificateId! > 0,
|
||||
certificateId: certificateId,
|
||||
enabled: enabled == 1,
|
||||
http2Support: http2Support == 1,
|
||||
hstsEnabled: hstsEnabled == 1,
|
||||
forceSSL: sslForced,
|
||||
accessListId: accessListId,
|
||||
cacheAssets: cachingEnabled == 1,
|
||||
blockExploits: blockExploits == 1,
|
||||
websocketSupport: allowWebsocketUpgrade == 1,
|
||||
locations: locations.map((l) => l.toEntity()).toList(),
|
||||
createdAt: createdOn != null ? DateTime.tryParse(createdOn!) : null,
|
||||
modifiedAt: modifiedOn != null ? DateTime.tryParse(modifiedOn!) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy location model.
|
||||
@freezed
|
||||
sealed class ProxyLocationModel with _$ProxyLocationModel {
|
||||
const factory ProxyLocationModel({
|
||||
required String path,
|
||||
@JsonKey(name: 'forward_scheme') required String forwardScheme,
|
||||
@JsonKey(name: 'forward_host') required String forwardHost,
|
||||
@JsonKey(name: 'forward_port') required int forwardPort,
|
||||
}) = _ProxyLocationModel;
|
||||
|
||||
const ProxyLocationModel._();
|
||||
|
||||
factory ProxyLocationModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProxyLocationModelFromJson(json);
|
||||
|
||||
ProxyLocation toEntity() {
|
||||
return ProxyLocation(
|
||||
path: path,
|
||||
forwardScheme: forwardScheme,
|
||||
forwardHost: forwardHost,
|
||||
forwardPort: forwardPort,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain info model for the /infrastructure/domains endpoint.
|
||||
///
|
||||
/// This is a simpler model used for listing domains.
|
||||
@freezed
|
||||
sealed class DomainInfoModel with _$DomainInfoModel {
|
||||
const factory DomainInfoModel({
|
||||
required String domain,
|
||||
required String service,
|
||||
@JsonKey(name: 'proxy_host_id') required int proxyHostId,
|
||||
@JsonKey(name: 'ssl_enabled') @Default(false) bool sslEnabled,
|
||||
@JsonKey(name: 'certificate_id') int? certificateId,
|
||||
}) = _DomainInfoModel;
|
||||
|
||||
factory DomainInfoModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$DomainInfoModelFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'proxy_host.freezed.dart';
|
||||
|
||||
/// NPM proxy host entity representing a domain configuration.
|
||||
@freezed
|
||||
sealed class ProxyHost with _$ProxyHost {
|
||||
const factory ProxyHost({
|
||||
/// Proxy host ID.
|
||||
required int id,
|
||||
|
||||
/// Domain names (can have multiple).
|
||||
required List<String> domainNames,
|
||||
|
||||
/// Forward scheme (http or https).
|
||||
required String forwardScheme,
|
||||
|
||||
/// Forward host (IP or hostname).
|
||||
required String forwardHost,
|
||||
|
||||
/// Forward port.
|
||||
required int forwardPort,
|
||||
|
||||
/// Whether SSL is enabled.
|
||||
required bool sslEnabled,
|
||||
|
||||
/// SSL certificate ID (if enabled).
|
||||
int? certificateId,
|
||||
|
||||
/// Whether the host is enabled.
|
||||
@Default(true) bool enabled,
|
||||
|
||||
/// Whether HTTP/2 is enabled.
|
||||
@Default(false) bool http2Support,
|
||||
|
||||
/// Whether HSTS is enabled.
|
||||
@Default(false) bool hstsEnabled,
|
||||
|
||||
/// Whether to force SSL.
|
||||
@Default(false) bool forceSSL,
|
||||
|
||||
/// Custom locations (advanced nginx config).
|
||||
@Default([]) List<ProxyLocation> locations,
|
||||
|
||||
/// Access list ID (for auth).
|
||||
int? accessListId,
|
||||
|
||||
/// Cache assets enabled.
|
||||
@Default(false) bool cacheAssets,
|
||||
|
||||
/// Block common exploits.
|
||||
@Default(false) bool blockExploits,
|
||||
|
||||
/// Websocket support.
|
||||
@Default(false) bool websocketSupport,
|
||||
|
||||
/// Created timestamp.
|
||||
DateTime? createdAt,
|
||||
|
||||
/// Modified timestamp.
|
||||
DateTime? modifiedAt,
|
||||
}) = _ProxyHost;
|
||||
|
||||
const ProxyHost._();
|
||||
|
||||
/// Primary domain (first in list).
|
||||
String get primaryDomain =>
|
||||
domainNames.isNotEmpty ? domainNames.first : 'Unknown';
|
||||
|
||||
/// Forward URL (scheme://host:port).
|
||||
String get forwardUrl => '$forwardScheme://$forwardHost:$forwardPort';
|
||||
|
||||
/// SSL status label.
|
||||
String get sslStatus => sslEnabled ? 'SSL Enabled' : 'No SSL';
|
||||
}
|
||||
|
||||
/// Proxy location for advanced routing.
|
||||
@freezed
|
||||
sealed class ProxyLocation with _$ProxyLocation {
|
||||
const factory ProxyLocation({
|
||||
/// Location path.
|
||||
required String path,
|
||||
|
||||
/// Forward scheme.
|
||||
required String forwardScheme,
|
||||
|
||||
/// Forward host.
|
||||
required String forwardHost,
|
||||
|
||||
/// Forward port.
|
||||
required int forwardPort,
|
||||
}) = _ProxyLocation;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/presentation/widgets/proxy_host_form.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
|
||||
|
||||
/// Unified page for proxy host create, view, and edit.
|
||||
///
|
||||
/// Usage:
|
||||
/// - Create: `ProxyHostPage.create(onClose: ...)`
|
||||
/// - View/Edit: `ProxyHostPage(proxyHostId: 123, onClose: ...)`
|
||||
class ProxyHostPage extends ConsumerStatefulWidget {
|
||||
const ProxyHostPage({
|
||||
super.key,
|
||||
required this.proxyHostId,
|
||||
required this.onClose,
|
||||
}) : _isCreate = false;
|
||||
|
||||
const ProxyHostPage.create({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
}) : proxyHostId = null,
|
||||
_isCreate = true;
|
||||
|
||||
final int? proxyHostId;
|
||||
final VoidCallback onClose;
|
||||
final bool _isCreate;
|
||||
|
||||
@override
|
||||
ConsumerState<ProxyHostPage> createState() => _ProxyHostPageState();
|
||||
}
|
||||
|
||||
class _ProxyHostPageState extends ConsumerState<ProxyHostPage>
|
||||
with EntityPageModeMixin {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Start in create mode if no ID, otherwise view mode
|
||||
mode = widget._isCreate ? EntityPageMode.create : EntityPageMode.view;
|
||||
}
|
||||
|
||||
String get _title {
|
||||
switch (mode) {
|
||||
case EntityPageMode.create:
|
||||
return 'New Proxy Host';
|
||||
case EntityPageMode.view:
|
||||
return 'Proxy Host Details';
|
||||
case EntityPageMode.edit:
|
||||
return 'Edit Proxy Host';
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSaved() {
|
||||
if (widget._isCreate) {
|
||||
// After create, close and return to list
|
||||
widget.onClose();
|
||||
} else {
|
||||
// After edit, return to view mode and refresh
|
||||
stopEditing();
|
||||
ref.invalidate(proxyHostProvider(widget.proxyHostId!));
|
||||
}
|
||||
ref.invalidate(domainsProvider);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Create mode - no need to fetch existing data
|
||||
if (widget._isCreate) {
|
||||
return EntityPageScaffold(
|
||||
title: _title,
|
||||
onBack: widget.onClose,
|
||||
child: ProxyHostForm(
|
||||
mode: EntityPageMode.create,
|
||||
onCancel: widget.onClose,
|
||||
onSaved: _handleSaved,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// View/Edit mode - fetch existing proxy host
|
||||
final proxyHostAsync = ref.watch(proxyHostProvider(widget.proxyHostId!));
|
||||
|
||||
return EntityPageScaffold(
|
||||
title: _title,
|
||||
onBack: widget.onClose,
|
||||
actions: [
|
||||
if (isViewing)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: 'Edit',
|
||||
onPressed: startEditing,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () =>
|
||||
ref.invalidate(proxyHostProvider(widget.proxyHostId!)),
|
||||
),
|
||||
],
|
||||
child: EntityAsyncContent<ProxyHost>(
|
||||
isLoading: proxyHostAsync.isLoading,
|
||||
error: proxyHostAsync.error,
|
||||
data: proxyHostAsync.value,
|
||||
onRetry: () => ref.invalidate(proxyHostProvider(widget.proxyHostId!)),
|
||||
builder: (proxyHost) {
|
||||
if (isEditing) {
|
||||
return ProxyHostForm(
|
||||
mode: EntityPageMode.edit,
|
||||
proxyHost: proxyHost,
|
||||
onCancel: stopEditing,
|
||||
onSaved: _handleSaved,
|
||||
);
|
||||
}
|
||||
return _ProxyHostView(proxyHost: proxyHost);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of proxy host details.
|
||||
class _ProxyHostView extends StatelessWidget {
|
||||
const _ProxyHostView({required this.proxyHost});
|
||||
|
||||
final ProxyHost proxyHost;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Domain names
|
||||
EntitySection(
|
||||
title: 'Domain Names',
|
||||
icon: Icons.public,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: proxyHost.domainNames.map((domain) {
|
||||
return Chip(
|
||||
avatar: Icon(
|
||||
proxyHost.sslEnabled ? Icons.lock : Icons.lock_open,
|
||||
size: 16,
|
||||
color: proxyHost.sslEnabled ? Colors.green : null,
|
||||
),
|
||||
label: Text(domain),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Forward destination
|
||||
EntitySection(
|
||||
title: 'Forward Destination',
|
||||
icon: Icons.arrow_forward,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.dns,
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
proxyHost.forwardUrl,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${proxyHost.forwardScheme.toUpperCase()} → ${proxyHost.forwardHost}:${proxyHost.forwardPort}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// SSL Settings
|
||||
EntitySection(
|
||||
title: 'SSL Settings',
|
||||
icon: Icons.verified_user,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
EntitySettingRow(
|
||||
label: 'SSL Enabled',
|
||||
value: proxyHost.sslEnabled,
|
||||
),
|
||||
if (proxyHost.sslEnabled) ...[
|
||||
const Divider(),
|
||||
EntitySettingRow(
|
||||
label: 'Force SSL',
|
||||
value: proxyHost.forceSSL,
|
||||
subtitle: 'Redirect HTTP to HTTPS',
|
||||
),
|
||||
const Divider(),
|
||||
EntitySettingRow(
|
||||
label: 'HTTP/2 Support',
|
||||
value: proxyHost.http2Support,
|
||||
),
|
||||
const Divider(),
|
||||
EntitySettingRow(
|
||||
label: 'HSTS Enabled',
|
||||
value: proxyHost.hstsEnabled,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Advanced Settings
|
||||
EntitySection(
|
||||
title: 'Advanced Settings',
|
||||
icon: Icons.settings,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
EntitySettingRow(
|
||||
label: 'WebSocket Support',
|
||||
value: proxyHost.websocketSupport,
|
||||
),
|
||||
const Divider(),
|
||||
EntitySettingRow(
|
||||
label: 'Block Exploits',
|
||||
value: proxyHost.blockExploits,
|
||||
),
|
||||
const Divider(),
|
||||
EntitySettingRow(
|
||||
label: 'Cache Assets',
|
||||
value: proxyHost.cacheAssets,
|
||||
),
|
||||
const Divider(),
|
||||
EntitySettingRow(
|
||||
label: 'Enabled',
|
||||
value: proxyHost.enabled,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Locations (if any)
|
||||
if (proxyHost.locations.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
EntitySection(
|
||||
title: 'Custom Locations',
|
||||
icon: Icons.route,
|
||||
child: Card(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: proxyHost.locations.length,
|
||||
separatorBuilder: (_, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final loc = proxyHost.locations[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.subdirectory_arrow_right),
|
||||
title: Text(loc.path,
|
||||
style: const TextStyle(fontFamily: 'monospace')),
|
||||
subtitle: Text(
|
||||
'${loc.forwardScheme}://${loc.forwardHost}:${loc.forwardPort}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_host_page.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
|
||||
import 'package:tatlock_ui/routing/url_state.dart';
|
||||
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
|
||||
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
|
||||
|
||||
/// Domain info data class for DataGrid display.
|
||||
class DomainData {
|
||||
DomainData({
|
||||
required this.domain,
|
||||
required this.service,
|
||||
required this.proxyHostId,
|
||||
required this.sslEnabled,
|
||||
this.certificateId,
|
||||
});
|
||||
|
||||
factory DomainData.fromJson(Map<String, dynamic> json) => DomainData(
|
||||
domain: json['domain'] as String,
|
||||
service: json['service'] as String,
|
||||
proxyHostId: json['proxy_host_id'] as int,
|
||||
sslEnabled: json['ssl_enabled'] as bool? ?? false,
|
||||
certificateId: json['certificate_id'] as int?,
|
||||
);
|
||||
|
||||
final String domain;
|
||||
final String service;
|
||||
final int proxyHostId;
|
||||
final bool sslEnabled;
|
||||
final int? certificateId;
|
||||
}
|
||||
|
||||
/// Page displaying the list of proxy hosts (domains) from NPM.
|
||||
class ProxyHostsPage extends ConsumerStatefulWidget {
|
||||
const ProxyHostsPage({super.key, this.routerState});
|
||||
|
||||
/// Router state for URL deep-linking.
|
||||
final GoRouterState? routerState;
|
||||
|
||||
@override
|
||||
ConsumerState<ProxyHostsPage> createState() => _ProxyHostsPageState();
|
||||
}
|
||||
|
||||
class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
|
||||
late final StateNotifierProvider<DataGridController<DomainData>,
|
||||
DataGridState<DomainData>> _gridProvider;
|
||||
late PageUrlState _urlState;
|
||||
Timer? _urlSyncTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Parse URL state
|
||||
_urlState = PageUrlState.fromQueryParams(
|
||||
widget.routerState?.uri.queryParameters ?? {},
|
||||
);
|
||||
|
||||
final dio = ref.read(coreApiClientProvider);
|
||||
final source = CoreApiDataSource<DomainData>(
|
||||
dio: dio,
|
||||
endpoint: '/infrastructure/domains',
|
||||
fromJson: DomainData.fromJson,
|
||||
);
|
||||
|
||||
// Initialize grid with URL state
|
||||
_gridProvider = dataGridProvider<DomainData>(
|
||||
source: source,
|
||||
config: _buildConfig(),
|
||||
idSelector: (d) => d.proxyHostId.toString(),
|
||||
initialSearch: _urlState.search,
|
||||
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
|
||||
initialSortDescending: _urlState.sortDescending,
|
||||
);
|
||||
|
||||
// Open document from URL if id present
|
||||
if (_urlState.id != null) {
|
||||
final id = int.tryParse(_urlState.id!);
|
||||
if (id != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(selectedProxyHostProvider.notifier).select(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlSyncTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Find column index by column ID.
|
||||
int? _columnIndexForId(String? columnId) {
|
||||
if (columnId == null) return null;
|
||||
final columns = _buildConfig().columns;
|
||||
for (var i = 0; i < columns.length; i++) {
|
||||
if (columns[i].id == columnId) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get column ID by index.
|
||||
String? _columnIdForIndex(int index) {
|
||||
final columns = _buildConfig().columns;
|
||||
if (index >= 0 && index < columns.length) {
|
||||
return columns[index].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Schedule URL sync with debounce.
|
||||
void _scheduleUrlSync() {
|
||||
_urlSyncTimer?.cancel();
|
||||
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
|
||||
}
|
||||
|
||||
/// Sync current state to URL.
|
||||
void _syncUrlParams() {
|
||||
final state = ref.read(_gridProvider);
|
||||
final selectedId = ref.read(selectedProxyHostProvider);
|
||||
|
||||
final params = PageUrlState(
|
||||
id: selectedId?.toString(),
|
||||
search: state.searchQuery.isEmpty ? null : state.searchQuery,
|
||||
sortColumn: state.sortColumnIndex != null
|
||||
? _columnIdForIndex(state.sortColumnIndex!)
|
||||
: null,
|
||||
sortDescending: state.sortDescending,
|
||||
).toQueryParams();
|
||||
|
||||
updateBrowserUrlParams(params);
|
||||
}
|
||||
|
||||
DataGridConfig<DomainData> _buildConfig() {
|
||||
return DataGridConfig<DomainData>(
|
||||
columns: [
|
||||
DataGridColumn<DomainData>(
|
||||
id: 'domain',
|
||||
header: 'Domain',
|
||||
valueBuilder: (d) => d.domain,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: const DataGridColumnWidth.flex(2),
|
||||
cellBuilder: (context, d) => _DomainCell(domain: d),
|
||||
),
|
||||
DataGridColumn<DomainData>(
|
||||
id: 'service',
|
||||
header: 'Service',
|
||||
valueBuilder: (d) => d.service,
|
||||
width: const DataGridColumnWidth.flex(1),
|
||||
cellBuilder: (context, d) => _ServiceCell(service: d.service),
|
||||
),
|
||||
DataGridColumn<DomainData>(
|
||||
id: 'ssl',
|
||||
header: 'SSL',
|
||||
valueBuilder: (d) => d.sslEnabled ? 'Enabled' : 'Disabled',
|
||||
width: const DataGridColumnWidth.fixed(100),
|
||||
alignment: DataGridColumnAlignment.end,
|
||||
cellBuilder: (context, d) => _SslBadge(enabled: d.sslEnabled),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
DataGridAction<DomainData>(
|
||||
icon: Icons.edit,
|
||||
label: 'Edit',
|
||||
onTap: (d) async => _viewDomain(d),
|
||||
),
|
||||
],
|
||||
enableSearch: true,
|
||||
searchHint: 'Search domains...',
|
||||
showHeader: true,
|
||||
showFooter: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _viewDomain(DomainData domain) {
|
||||
ref.read(selectedProxyHostProvider.notifier).select(domain.proxyHostId);
|
||||
// Navigate with id to enable back button
|
||||
context.go('/control-room/proxy-hosts?id=${domain.proxyHostId}');
|
||||
}
|
||||
|
||||
void _createNew() {
|
||||
ref.read(creatingProxyHostProvider.notifier).start();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedId = ref.watch(selectedProxyHostProvider);
|
||||
final isCreating = ref.watch(creatingProxyHostProvider);
|
||||
|
||||
// Listen for grid state changes to sync URL
|
||||
ref.listen(_gridProvider, (previous, next) {
|
||||
if (previous?.searchQuery != next.searchQuery ||
|
||||
previous?.sortColumnIndex != next.sortColumnIndex ||
|
||||
previous?.sortDescending != next.sortDescending) {
|
||||
_scheduleUrlSync();
|
||||
}
|
||||
});
|
||||
|
||||
// Show create page if creating new
|
||||
if (isCreating) {
|
||||
return ProxyHostPage.create(
|
||||
onClose: () {
|
||||
ref.read(creatingProxyHostProvider.notifier).stop();
|
||||
ref.read(_gridProvider.notifier).refresh();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Show detail page if a proxy host is selected
|
||||
if (selectedId != null) {
|
||||
return ProxyHostPage(
|
||||
proxyHostId: selectedId,
|
||||
onClose: () {
|
||||
ref.read(selectedProxyHostProvider.notifier).clear();
|
||||
// Clear id from URL
|
||||
context.go('/control-room/proxy-hosts');
|
||||
ref.read(_gridProvider.notifier).refresh();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return DataGrid<DomainData>(
|
||||
provider: _gridProvider,
|
||||
config: _buildConfig(),
|
||||
idSelector: (d) => d.proxyHostId.toString(),
|
||||
toolbarActions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => ref.read(_gridProvider.notifier).refresh(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _createNew,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DomainCell extends StatelessWidget {
|
||||
const _DomainCell({required this.domain});
|
||||
|
||||
final DomainData domain;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: domain.sslEnabled
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
domain.sslEnabled ? Icons.lock : Icons.lock_open,
|
||||
size: 20,
|
||||
color: domain.sslEnabled ? Colors.green : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
domain.domain,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceCell extends StatelessWidget {
|
||||
const _ServiceCell({required this.service});
|
||||
|
||||
final String service;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Text(
|
||||
service,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SslBadge extends StatelessWidget {
|
||||
const _SslBadge({required this.enabled});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
enabled ? 'SSL' : 'HTTP',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: enabled ? Colors.green : colorScheme.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/data/datasources/proxy_hosts_datasource.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
|
||||
|
||||
part 'proxy_hosts_provider.g.dart';
|
||||
|
||||
/// Provider for the list of configured domains.
|
||||
@riverpod
|
||||
Future<List<DomainInfoModel>> domains(Ref ref) async {
|
||||
final datasource = ref.watch(proxyHostsDatasourceProvider);
|
||||
return datasource.getDomains();
|
||||
}
|
||||
|
||||
/// Provider for a specific proxy host details.
|
||||
@riverpod
|
||||
Future<ProxyHost> proxyHost(Ref ref, int proxyId) async {
|
||||
final datasource = ref.watch(proxyHostsDatasourceProvider);
|
||||
final model = await datasource.getProxyHost(proxyId);
|
||||
return model.toEntity();
|
||||
}
|
||||
|
||||
/// Provider for selected proxy host ID (for detail view).
|
||||
@riverpod
|
||||
class SelectedProxyHost extends _$SelectedProxyHost {
|
||||
@override
|
||||
int? build() => null;
|
||||
|
||||
void select(int id) => state = id;
|
||||
void clear() => state = null;
|
||||
}
|
||||
|
||||
/// Provider for tracking if we're creating a new proxy host.
|
||||
@riverpod
|
||||
class CreatingProxyHost extends _$CreatingProxyHost {
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
void start() => state = true;
|
||||
void stop() => state = false;
|
||||
}
|
||||
|
||||
/// Provider for creating a new proxy host.
|
||||
@riverpod
|
||||
Future<void> createProxyHost(
|
||||
Ref ref, {
|
||||
required List<String> domainNames,
|
||||
required String forwardScheme,
|
||||
required String forwardHost,
|
||||
required int forwardPort,
|
||||
bool sslEnabled = false,
|
||||
}) async {
|
||||
final datasource = ref.watch(proxyHostsDatasourceProvider);
|
||||
await datasource.createProxyHost(
|
||||
domainNames: domainNames,
|
||||
forwardScheme: forwardScheme,
|
||||
forwardHost: forwardHost,
|
||||
forwardPort: forwardPort,
|
||||
sslEnabled: sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Provider for updating an existing proxy host.
|
||||
@riverpod
|
||||
Future<void> updateProxyHost(
|
||||
Ref ref, {
|
||||
required int id,
|
||||
required Map<String, dynamic> config,
|
||||
}) async {
|
||||
final datasource = ref.watch(proxyHostsDatasourceProvider);
|
||||
await datasource.updateProxyHost(id, config);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/entity_form_dialog.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
|
||||
|
||||
/// Form for creating or editing a proxy host.
|
||||
class ProxyHostForm extends ConsumerStatefulWidget {
|
||||
const ProxyHostForm({
|
||||
super.key,
|
||||
this.proxyHost,
|
||||
this.mode = EntityPageMode.create,
|
||||
required this.onCancel,
|
||||
required this.onSaved,
|
||||
});
|
||||
|
||||
/// Existing proxy host for editing (null for create).
|
||||
final ProxyHost? proxyHost;
|
||||
|
||||
/// Form mode - create or edit.
|
||||
final EntityPageMode mode;
|
||||
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
bool get isEditing => mode == EntityPageMode.edit;
|
||||
|
||||
@override
|
||||
ConsumerState<ProxyHostForm> createState() => _ProxyHostFormState();
|
||||
}
|
||||
|
||||
class _ProxyHostFormState extends ConsumerState<ProxyHostForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late final TextEditingController _domainController;
|
||||
late final TextEditingController _forwardHostController;
|
||||
late final TextEditingController _forwardPortController;
|
||||
|
||||
late String _forwardScheme;
|
||||
late bool _forceSSL;
|
||||
late bool _http2Support;
|
||||
late bool _websocketSupport;
|
||||
late bool _blockExploits;
|
||||
late bool _cacheAssets;
|
||||
|
||||
bool _isSaving = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final host = widget.proxyHost;
|
||||
|
||||
_domainController = TextEditingController(
|
||||
text: host?.domainNames.join(', ') ?? '',
|
||||
);
|
||||
_forwardHostController = TextEditingController(
|
||||
text: host?.forwardHost ?? '',
|
||||
);
|
||||
_forwardPortController = TextEditingController(
|
||||
text: host?.forwardPort.toString() ?? '80',
|
||||
);
|
||||
|
||||
_forwardScheme = host?.forwardScheme ?? 'http';
|
||||
_forceSSL = host?.forceSSL ?? false;
|
||||
_http2Support = host?.http2Support ?? false;
|
||||
_websocketSupport = host?.websocketSupport ?? false;
|
||||
_blockExploits = host?.blockExploits ?? true;
|
||||
_cacheAssets = host?.cacheAssets ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_domainController.dispose();
|
||||
_forwardHostController.dispose();
|
||||
_forwardPortController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleSave() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final domains = _domainController.text
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.where((d) => d.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (widget.isEditing) {
|
||||
await ref.read(updateProxyHostProvider(
|
||||
id: widget.proxyHost!.id,
|
||||
config: {
|
||||
'domain_names': domains,
|
||||
'forward_scheme': _forwardScheme,
|
||||
'forward_host': _forwardHostController.text,
|
||||
'forward_port': int.parse(_forwardPortController.text),
|
||||
'ssl_forced': _forceSSL,
|
||||
'http2_support': _http2Support ? 1 : 0,
|
||||
'allow_websocket_upgrade': _websocketSupport ? 1 : 0,
|
||||
'block_exploits': _blockExploits ? 1 : 0,
|
||||
'caching_enabled': _cacheAssets ? 1 : 0,
|
||||
},
|
||||
).future);
|
||||
} else {
|
||||
await ref.read(createProxyHostProvider(
|
||||
domainNames: domains,
|
||||
forwardScheme: _forwardScheme,
|
||||
forwardHost: _forwardHostController.text,
|
||||
forwardPort: int.parse(_forwardPortController.text),
|
||||
).future);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(widget.isEditing
|
||||
? 'Proxy host updated successfully'
|
||||
: 'Proxy host created successfully'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
widget.onSaved();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return EntityForm(
|
||||
formKey: _formKey,
|
||||
mode: widget.mode,
|
||||
onCancel: widget.onCancel,
|
||||
onSave: _handleSave,
|
||||
isSaving: _isSaving,
|
||||
error: _error,
|
||||
children: [
|
||||
// Domain Names
|
||||
Text('Domain Names', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _domainController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'example.com, www.example.com',
|
||||
helperText: 'Separate multiple domains with commas',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'At least one domain is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Forward Destination
|
||||
Text('Forward Destination', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Scheme dropdown
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _forwardScheme,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Scheme',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'http', child: Text('HTTP')),
|
||||
DropdownMenuItem(value: 'https', child: Text('HTTPS')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _forwardScheme = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Host
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextFormField(
|
||||
controller: _forwardHostController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Host',
|
||||
hintText: '192.168.1.100 or hostname',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Host is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Port
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: TextFormField(
|
||||
controller: _forwardPortController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Port',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Required';
|
||||
}
|
||||
final port = int.tryParse(value);
|
||||
if (port == null || port < 1 || port > 65535) {
|
||||
return 'Invalid';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// SSL & Security Options
|
||||
Text('SSL & Security', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Force SSL'),
|
||||
subtitle: const Text('Redirect HTTP to HTTPS'),
|
||||
value: _forceSSL,
|
||||
onChanged: (v) => setState(() => _forceSSL = v),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
title: const Text('HTTP/2 Support'),
|
||||
value: _http2Support,
|
||||
onChanged: (v) => setState(() => _http2Support = v),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
title: const Text('Block Common Exploits'),
|
||||
value: _blockExploits,
|
||||
onChanged: (v) => setState(() => _blockExploits = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Advanced Options
|
||||
Text('Advanced', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('WebSocket Support'),
|
||||
value: _websocketSupport,
|
||||
onChanged: (v) => setState(() => _websocketSupport = v),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
title: const Text('Cache Assets'),
|
||||
value: _cacheAssets,
|
||||
onChanged: (v) => setState(() => _cacheAssets = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import 'package:flutter/material.dart' hide Stack;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_hosts_page.dart';
|
||||
import 'package:tatlock_ui/features/control_room/router.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/data/repositories/stack_repository_impl.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/pages/stack_detail_page.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/stack_list_tile.dart';
|
||||
import 'package:tatlock_ui/shared/layouts/widgets/filter_panel.dart';
|
||||
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
|
||||
|
||||
/// Main Control Room page with nav panel and section content.
|
||||
class ControlRoomPage extends ConsumerWidget {
|
||||
const ControlRoomPage({
|
||||
super.key,
|
||||
this.nav = ControlRoomNav.containers,
|
||||
this.routerState,
|
||||
});
|
||||
|
||||
/// The current nav item to display.
|
||||
final ControlRoomNav nav;
|
||||
|
||||
/// Router state for URL deep-linking (query params).
|
||||
final GoRouterState? routerState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
// Nav Panel - section navigation with grouping
|
||||
NavPanel(
|
||||
title: 'Sections',
|
||||
icon: Icons.dns_outlined,
|
||||
items: ControlRoomNav.values.map((n) => n.toNavItem()).toList(),
|
||||
selectedId: nav.id,
|
||||
onItemSelected: (id) {
|
||||
final newNav = ControlRoomNav.values.firstWhere(
|
||||
(n) => n.id == id,
|
||||
);
|
||||
// Navigate to section URL
|
||||
context.go(pathForNav(newNav));
|
||||
// Clear stack selection when changing sections
|
||||
ref.read(selectedStackProvider.notifier).clear();
|
||||
},
|
||||
),
|
||||
// Divider
|
||||
VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
// Section content
|
||||
Expanded(
|
||||
child: _SectionContent(nav: nav, routerState: routerState),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders content for the selected nav item.
|
||||
class _SectionContent extends ConsumerWidget {
|
||||
const _SectionContent({required this.nav, this.routerState});
|
||||
|
||||
final ControlRoomNav nav;
|
||||
final GoRouterState? routerState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return switch (nav) {
|
||||
ControlRoomNav.containers => _ContainersSection(routerState: routerState),
|
||||
ControlRoomNav.proxyHosts => ProxyHostsPage(routerState: routerState),
|
||||
_ => _PlaceholderSection(nav: nav),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder for nav items not yet implemented.
|
||||
class _PlaceholderSection extends StatelessWidget {
|
||||
const _PlaceholderSection({required this.nav});
|
||||
|
||||
final ControlRoomNav nav;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
nav.icon,
|
||||
size: 64,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
nav.label,
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${nav.section} • Coming soon',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Containers section with optional stack filter.
|
||||
class _ContainersSection extends ConsumerWidget {
|
||||
const _ContainersSection({this.routerState});
|
||||
|
||||
final GoRouterState? routerState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedStack = ref.watch(selectedStackProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Stacks filter panel
|
||||
_StacksFilterPanel(routerState: routerState),
|
||||
// Divider
|
||||
VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant,
|
||||
),
|
||||
// Main content - containers list or stack detail
|
||||
Expanded(
|
||||
child: selectedStack == null
|
||||
? ContainersListPage(routerState: routerState)
|
||||
: StackDetailPage(stackId: selectedStack),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stacks filter panel for Containers section (includes "All Containers" option).
|
||||
class _StacksFilterPanel extends ConsumerWidget {
|
||||
const _StacksFilterPanel({this.routerState});
|
||||
|
||||
final GoRouterState? routerState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final stacksAsync = ref.watch(stacksProvider);
|
||||
final selectedStack = ref.watch(selectedStackProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return FilterPanel(
|
||||
title: 'Stacks',
|
||||
icon: Icons.layers,
|
||||
onRefresh: () => ref.invalidate(stacksProvider),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// All containers option
|
||||
ListTile(
|
||||
selected: selectedStack == null,
|
||||
selectedTileColor:
|
||||
colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
leading: Icon(
|
||||
Icons.all_inbox,
|
||||
color: selectedStack == null
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: const Text('All Containers'),
|
||||
onTap: () => ref.read(selectedStackProvider.notifier).clear(),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Stacks list
|
||||
Expanded(
|
||||
child: stacksAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Failed to load stacks',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (stacks) => _StacksList(
|
||||
stacks: stacks,
|
||||
selectedStackId: selectedStack,
|
||||
routerState: routerState,
|
||||
onStackSelected: (id) =>
|
||||
ref.read(selectedStackProvider.notifier).select(id),
|
||||
onStackAction: (id, action) =>
|
||||
_handleStackAction(context, ref, id, action),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleStackAction(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String stackId,
|
||||
String action,
|
||||
) async {
|
||||
final repository = ref.read(stackRepositoryProvider);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
await repository.startStack(stackId);
|
||||
case 'stop':
|
||||
await repository.stopStack(stackId);
|
||||
case 'restart':
|
||||
await repository.restartStack(stackId);
|
||||
}
|
||||
|
||||
// Refresh data
|
||||
ref.invalidate(stacksProvider);
|
||||
|
||||
// Show success snackbar
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Stack ${action}ed successfully'),
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Failed to $action stack: ${e.toString()}',
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _StacksList extends StatefulWidget {
|
||||
const _StacksList({
|
||||
required this.stacks,
|
||||
required this.selectedStackId,
|
||||
required this.onStackSelected,
|
||||
required this.onStackAction,
|
||||
this.routerState,
|
||||
});
|
||||
|
||||
final List<Stack> stacks;
|
||||
final String? selectedStackId;
|
||||
final void Function(String) onStackSelected;
|
||||
final void Function(String, String) onStackAction;
|
||||
final GoRouterState? routerState;
|
||||
|
||||
@override
|
||||
State<_StacksList> createState() => _StacksListState();
|
||||
}
|
||||
|
||||
class _StacksListState extends State<_StacksList> {
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Stack> get _filteredStacks {
|
||||
if (_searchQuery.isEmpty) return widget.stacks;
|
||||
final query = _searchQuery.toLowerCase();
|
||||
return widget.stacks.where((s) {
|
||||
return s.name.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final filtered = _filteredStacks;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Search field
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search stacks...',
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _searchQuery = '');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
onChanged: (value) => setState(() => _searchQuery = value),
|
||||
),
|
||||
),
|
||||
// Stack list or empty state
|
||||
Expanded(
|
||||
child: filtered.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_searchQuery.isEmpty
|
||||
? Icons.layers_clear
|
||||
: Icons.search_off,
|
||||
size: 32,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'No stacks found'
|
||||
: 'No stacks match "$_searchQuery"',
|
||||
style: TextStyle(
|
||||
color: colorScheme.outline,
|
||||
fontSize: 13,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final stack = filtered[index];
|
||||
return StackListTile(
|
||||
stack: stack,
|
||||
isSelected: stack.id == widget.selectedStackId,
|
||||
onTap: () => widget.onStackSelected(stack.id),
|
||||
onStart: () => widget.onStackAction(stack.id, 'start'),
|
||||
onStop: () => widget.onStackAction(stack.id, 'stop'),
|
||||
onRestart: () => widget.onStackAction(stack.id, 'restart'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart';
|
||||
import 'package:tatlock_ui/routing/app_router.dart';
|
||||
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
|
||||
|
||||
/// Route paths for Control Room.
|
||||
abstract class ControlRoomRoutes {
|
||||
static const base = '/control-room';
|
||||
// Stack
|
||||
static const containers = '/control-room/containers';
|
||||
static const proxyHosts = '/control-room/proxy-hosts';
|
||||
// Data Management
|
||||
static const postgres = '/control-room/postgres';
|
||||
static const redis = '/control-room/redis';
|
||||
static const qdrant = '/control-room/qdrant';
|
||||
static const neo4j = '/control-room/neo4j';
|
||||
}
|
||||
|
||||
/// Control Room navigation items with section grouping.
|
||||
enum ControlRoomNav {
|
||||
// Stack Management section - Docker containers and reverse proxy
|
||||
containers('containers', 'Containers', Icons.dns, 'Stack Management'),
|
||||
proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'Stack Management'),
|
||||
// Data Management section - Database browsers
|
||||
postgres('postgres', 'PostgreSQL', Icons.table_chart, 'Data Management'),
|
||||
redis('redis', 'Redis', Icons.memory, 'Data Management'),
|
||||
qdrant('qdrant', 'Qdrant', Icons.scatter_plot, 'Data Management'),
|
||||
neo4j('neo4j', 'Neo4j', Icons.hub, 'Data Management');
|
||||
|
||||
const ControlRoomNav(this.id, this.label, this.icon, this.section);
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String section;
|
||||
|
||||
NavItem toNavItem() => NavItem(
|
||||
id: id,
|
||||
label: label,
|
||||
icon: icon,
|
||||
section: section,
|
||||
);
|
||||
|
||||
/// Get route path for this nav item.
|
||||
String get path => '/control-room/$id';
|
||||
}
|
||||
|
||||
/// Get route path for a nav item.
|
||||
String pathForNav(ControlRoomNav nav) => nav.path;
|
||||
|
||||
/// Control Room room definition.
|
||||
final controlRoomRoom = RoomDefinition(
|
||||
id: 'control-room',
|
||||
label: 'Control Room',
|
||||
icon: Icons.dns_outlined,
|
||||
selectedIcon: Icons.dns,
|
||||
defaultRoute: ControlRoomRoutes.containers,
|
||||
routes: controlRoomRoutes,
|
||||
// No permissions required - accessible to all authenticated users
|
||||
);
|
||||
|
||||
/// Register Control Room with the room registry.
|
||||
void registerControlRoom() {
|
||||
roomRegistry.register(controlRoomRoom);
|
||||
}
|
||||
|
||||
/// Control Room routes for go_router.
|
||||
List<RouteBase> controlRoomRoutes() {
|
||||
return [
|
||||
// Redirect /control-room to /control-room/containers
|
||||
GoRoute(
|
||||
path: ControlRoomRoutes.base,
|
||||
name: 'controlRoom',
|
||||
redirect: (context, state) => ControlRoomRoutes.containers,
|
||||
),
|
||||
// Generate routes for all nav items
|
||||
for (final nav in ControlRoomNav.values)
|
||||
GoRoute(
|
||||
path: nav.path,
|
||||
name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}',
|
||||
pageBuilder: (context, state) => noTransitionPage(
|
||||
context,
|
||||
state,
|
||||
ControlRoomPage(nav: nav, routerState: state),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String _capitalize(String s) {
|
||||
if (s.isEmpty) return s;
|
||||
return s
|
||||
.split('_')
|
||||
.map((part) => part[0].toUpperCase() + part.substring(1))
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/data/datasources/containers_datasource.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
||||
|
||||
part 'stacks_datasource.g.dart';
|
||||
|
||||
/// Data source for stack operations.
|
||||
///
|
||||
/// Stacks are derived from container labels (Docker Compose convention).
|
||||
/// The Core API provides endpoints for stack YAML and environment variables.
|
||||
class StacksDatasource {
|
||||
StacksDatasource(this._dio, this._containersDatasource);
|
||||
|
||||
final Dio _dio;
|
||||
final ContainersDatasource _containersDatasource;
|
||||
|
||||
/// Gets all stacks by aggregating container data.
|
||||
Future<List<Stack>> getStacks() async {
|
||||
final containers = await _containersDatasource.getContainers();
|
||||
final containerEntities = containers.map((c) => c.toEntity()).toList();
|
||||
|
||||
// Group containers by stack name
|
||||
final stackMap = <String, List<Container>>{};
|
||||
|
||||
for (final container in containerEntities) {
|
||||
final stackName = container.stackName;
|
||||
if (stackName != null) {
|
||||
stackMap.putIfAbsent(stackName, () => []).add(container);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Stack entities
|
||||
return stackMap.entries.map((entry) {
|
||||
final name = entry.key;
|
||||
final stackContainers = entry.value;
|
||||
final runningCount = stackContainers.where((c) => c.isRunning).length;
|
||||
|
||||
return Stack(
|
||||
id: name, // Use name as ID for compose stacks
|
||||
name: name,
|
||||
type: StackType.compose,
|
||||
status: runningCount == stackContainers.length
|
||||
? StackStatus.active
|
||||
: runningCount > 0
|
||||
? StackStatus.active
|
||||
: StackStatus.inactive,
|
||||
containerCount: stackContainers.length,
|
||||
runningCount: runningCount,
|
||||
);
|
||||
}).toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
}
|
||||
|
||||
/// Gets a single stack by ID.
|
||||
Future<Stack> getStack(String id) async {
|
||||
final stacks = await getStacks();
|
||||
return stacks.firstWhere(
|
||||
(s) => s.id == id,
|
||||
orElse: () => throw Exception('Stack not found: $id'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Performs an action on all containers in a stack.
|
||||
Future<void> stackAction(String stackId, String action) async {
|
||||
final containers = await _containersDatasource.getContainers();
|
||||
final stackContainers = containers
|
||||
.map((c) => c.toEntity())
|
||||
.where((c) => c.stackId == stackId)
|
||||
.toList();
|
||||
|
||||
for (final container in stackContainers) {
|
||||
await _containersDatasource.containerAction(container.fullId, action);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the compose YAML for a stack.
|
||||
Future<String> getStackYaml(String stackId) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/infrastructure/stacks/$stackId/compose',
|
||||
);
|
||||
return response.data ?? '';
|
||||
}
|
||||
|
||||
/// Updates the compose YAML for a stack.
|
||||
Future<void> updateStackYaml(String stackId, String yaml) async {
|
||||
await _dio.put<void>(
|
||||
'/infrastructure/stacks/$stackId/compose',
|
||||
data: yaml,
|
||||
options: Options(contentType: 'text/yaml'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets the environment variables for a stack.
|
||||
Future<Map<String, String>> getStackEnvVars(String stackId) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/infrastructure/stacks/$stackId/env',
|
||||
);
|
||||
return response.data?.map((k, v) => MapEntry(k, v.toString())) ?? {};
|
||||
}
|
||||
|
||||
/// Updates the environment variables for a stack.
|
||||
Future<void> updateStackEnvVars(
|
||||
String stackId,
|
||||
Map<String, String> envVars,
|
||||
) async {
|
||||
await _dio.put<void>(
|
||||
'/infrastructure/stacks/$stackId/env',
|
||||
data: envVars,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deploys/redeploys a stack with current configuration.
|
||||
Future<void> deployStack(String stackId) async {
|
||||
await _dio.post<void>('/infrastructure/stacks/$stackId/deploy');
|
||||
}
|
||||
|
||||
/// Rebuilds a stack (pulls fresh images and recreates containers).
|
||||
Future<void> rebuildStack(String stackId) async {
|
||||
await _dio.post<void>('/infrastructure/stacks/$stackId/rebuild');
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the stacks datasource.
|
||||
@riverpod
|
||||
StacksDatasource stacksDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
final containersDatasource = ref.watch(containersDatasourceProvider);
|
||||
return StacksDatasource(dio, containersDatasource);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
||||
|
||||
part 'stack_model.freezed.dart';
|
||||
part 'stack_model.g.dart';
|
||||
|
||||
/// Stack data model for API serialization.
|
||||
@freezed
|
||||
sealed class StackModel with _$StackModel {
|
||||
const factory StackModel({
|
||||
required String id,
|
||||
required String name,
|
||||
@JsonKey(name: 'type') String? typeString,
|
||||
@JsonKey(name: 'status') String? statusString,
|
||||
@JsonKey(name: 'container_count') @Default(0) int containerCount,
|
||||
@JsonKey(name: 'running_count') @Default(0) int runningCount,
|
||||
@JsonKey(name: 'compose_file') String? composeFile,
|
||||
String? environment,
|
||||
@JsonKey(name: 'created_at') String? createdAt,
|
||||
@JsonKey(name: 'updated_at') String? updatedAt,
|
||||
}) = _StackModel;
|
||||
|
||||
const StackModel._();
|
||||
|
||||
factory StackModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$StackModelFromJson(json);
|
||||
|
||||
/// Converts to domain entity.
|
||||
Stack toEntity() {
|
||||
return Stack(
|
||||
id: id,
|
||||
name: name,
|
||||
type: _parseStackType(typeString),
|
||||
status: _parseStackStatus(statusString),
|
||||
containerCount: containerCount,
|
||||
runningCount: runningCount,
|
||||
composeFile: composeFile,
|
||||
environment: environment,
|
||||
createdAt: createdAt != null ? DateTime.tryParse(createdAt!) : null,
|
||||
updatedAt: updatedAt != null ? DateTime.tryParse(updatedAt!) : null,
|
||||
);
|
||||
}
|
||||
|
||||
StackType _parseStackType(String? type) {
|
||||
return switch (type?.toLowerCase()) {
|
||||
'compose' => StackType.compose,
|
||||
'swarm' => StackType.swarm,
|
||||
'kubernetes' || 'k8s' => StackType.kubernetes,
|
||||
_ => StackType.compose,
|
||||
};
|
||||
}
|
||||
|
||||
StackStatus _parseStackStatus(String? status) {
|
||||
return switch (status?.toLowerCase()) {
|
||||
'active' || 'running' => StackStatus.active,
|
||||
'inactive' || 'stopped' => StackStatus.inactive,
|
||||
'error' => StackStatus.error,
|
||||
_ => StackStatus.unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/data/datasources/stacks_datasource.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/repositories/stack_repository.dart';
|
||||
|
||||
part 'stack_repository_impl.g.dart';
|
||||
|
||||
/// Implementation of StackRepository.
|
||||
class StackRepositoryImpl implements StackRepository {
|
||||
StackRepositoryImpl(this._datasource);
|
||||
|
||||
final StacksDatasource _datasource;
|
||||
|
||||
@override
|
||||
Future<List<Stack>> getStacks() async {
|
||||
return _datasource.getStacks();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stack> getStack(String id) async {
|
||||
return _datasource.getStack(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startStack(String id) async {
|
||||
await _datasource.stackAction(id, 'start');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopStack(String id) async {
|
||||
await _datasource.stackAction(id, 'stop');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restartStack(String id) async {
|
||||
await _datasource.stackAction(id, 'restart');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeStack(String id) async {
|
||||
// TODO: Implement stack removal
|
||||
throw UnimplementedError('Stack removal not yet implemented');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getStackYaml(String id) async {
|
||||
return _datasource.getStackYaml(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStackYaml(String id, String yaml) async {
|
||||
await _datasource.updateStackYaml(id, yaml);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> getStackEnvVars(String id) async {
|
||||
return _datasource.getStackEnvVars(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStackEnvVars(String id, Map<String, String> envVars) async {
|
||||
await _datasource.updateStackEnvVars(id, envVars);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deployStack(String id) async {
|
||||
await _datasource.deployStack(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rebuildStack(String id) async {
|
||||
await _datasource.rebuildStack(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the stack repository.
|
||||
@riverpod
|
||||
StackRepository stackRepository(Ref ref) {
|
||||
final datasource = ref.watch(stacksDatasourceProvider);
|
||||
return StackRepositoryImpl(datasource);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'stack.freezed.dart';
|
||||
|
||||
/// Container orchestration stack (Docker Compose stack).
|
||||
@freezed
|
||||
sealed class Stack with _$Stack {
|
||||
const factory Stack({
|
||||
/// Unique stack identifier.
|
||||
required String id,
|
||||
|
||||
/// Stack name.
|
||||
required String name,
|
||||
|
||||
/// Stack type (compose, swarm, kubernetes).
|
||||
@Default(StackType.compose) StackType type,
|
||||
|
||||
/// Current stack status.
|
||||
@Default(StackStatus.unknown) StackStatus status,
|
||||
|
||||
/// Number of containers in the stack.
|
||||
@Default(0) int containerCount,
|
||||
|
||||
/// Number of running containers.
|
||||
@Default(0) int runningCount,
|
||||
|
||||
/// Path to the compose file (if applicable).
|
||||
String? composeFile,
|
||||
|
||||
/// Compose YAML content.
|
||||
String? yaml,
|
||||
|
||||
/// Environment variables for the stack.
|
||||
@Default({}) Map<String, String> envVars,
|
||||
|
||||
/// Environment name (e.g., production, staging).
|
||||
String? environment,
|
||||
|
||||
/// When the stack was created.
|
||||
DateTime? createdAt,
|
||||
|
||||
/// When the stack was last updated.
|
||||
DateTime? updatedAt,
|
||||
}) = _Stack;
|
||||
|
||||
const Stack._();
|
||||
|
||||
/// Whether all containers in the stack are running.
|
||||
bool get isHealthy => runningCount == containerCount && containerCount > 0;
|
||||
|
||||
/// Whether the stack has any running containers.
|
||||
bool get hasRunningContainers => runningCount > 0;
|
||||
|
||||
/// Whether the stack is partially running.
|
||||
bool get isPartial =>
|
||||
runningCount > 0 && runningCount < containerCount;
|
||||
}
|
||||
|
||||
/// Stack orchestration type.
|
||||
enum StackType {
|
||||
compose,
|
||||
swarm,
|
||||
kubernetes,
|
||||
}
|
||||
|
||||
/// Stack status.
|
||||
enum StackStatus {
|
||||
active,
|
||||
inactive,
|
||||
error,
|
||||
unknown,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
||||
|
||||
/// Repository interface for stack operations.
|
||||
abstract class StackRepository {
|
||||
/// Gets all stacks.
|
||||
Future<List<Stack>> getStacks();
|
||||
|
||||
/// Gets a single stack by ID.
|
||||
Future<Stack> getStack(String id);
|
||||
|
||||
/// Starts all containers in a stack.
|
||||
Future<void> startStack(String id);
|
||||
|
||||
/// Stops all containers in a stack.
|
||||
Future<void> stopStack(String id);
|
||||
|
||||
/// Restarts all containers in a stack.
|
||||
Future<void> restartStack(String id);
|
||||
|
||||
/// Removes a stack (stops and removes containers).
|
||||
Future<void> removeStack(String id);
|
||||
|
||||
/// Gets the compose YAML for a stack.
|
||||
Future<String> getStackYaml(String id);
|
||||
|
||||
/// Updates the compose YAML for a stack.
|
||||
Future<void> updateStackYaml(String id, String yaml);
|
||||
|
||||
/// Gets the environment variables for a stack.
|
||||
Future<Map<String, String>> getStackEnvVars(String id);
|
||||
|
||||
/// Updates the environment variables for a stack.
|
||||
Future<void> updateStackEnvVars(String id, Map<String, String> envVars);
|
||||
|
||||
/// Deploys/redeploys a stack with current configuration.
|
||||
Future<void> deployStack(String id);
|
||||
|
||||
/// Rebuilds a stack (pulls fresh images and recreates containers).
|
||||
Future<void> rebuildStack(String id);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import 'package:flutter/material.dart' hide Stack;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart'
|
||||
as container_entity;
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/providers/containers_provider.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_logs_viewer.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/env_vars_editor.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/yaml_editor.dart';
|
||||
|
||||
/// Detail page for a selected stack with YAML editor, env vars, and container list.
|
||||
class StackDetailPage extends ConsumerStatefulWidget {
|
||||
const StackDetailPage({
|
||||
super.key,
|
||||
required this.stackId,
|
||||
});
|
||||
|
||||
final String stackId;
|
||||
|
||||
@override
|
||||
ConsumerState<StackDetailPage> createState() => _StackDetailPageState();
|
||||
}
|
||||
|
||||
class _StackDetailPageState extends ConsumerState<StackDetailPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stackAsync = ref.watch(stackProvider(widget.stackId));
|
||||
final containersAsync = ref.watch(containersProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Listen for config action results
|
||||
ref.listen<AsyncValue<void>>(stackConfigActionsProvider, (previous, next) {
|
||||
if (previous?.isLoading == true && !next.isLoading) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Configuration saved'),
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to save: $error'),
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Stack editor (top section - 60%)
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: stackAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('Error: $error')),
|
||||
data: (stack) => _StackEditor(
|
||||
stack: stack,
|
||||
tabController: _tabController,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Divider with drag handle appearance
|
||||
Container(
|
||||
height: 8,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outline,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Container list (bottom section - 40%)
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: containersAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('Error: $error')),
|
||||
data: (containers) => _CompactContainerList(
|
||||
containers: containers,
|
||||
stackId: widget.stackId,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack configuration editor with tabs for YAML and environment variables.
|
||||
class _StackEditor extends ConsumerWidget {
|
||||
const _StackEditor({
|
||||
required this.stack,
|
||||
required this.tabController,
|
||||
});
|
||||
|
||||
final Stack stack;
|
||||
final TabController tabController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header with stack info and actions
|
||||
Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
alignment: Alignment.bottomCenter,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(Icons.layers, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
stack.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${stack.runningCount}/${stack.containerCount} containers running',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(stackConfigActionsProvider.notifier).rebuild(stack.id);
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Rebuild'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(stackConfigActionsProvider.notifier).deploy(stack.id);
|
||||
},
|
||||
icon: const Icon(Icons.rocket_launch, size: 18),
|
||||
label: const Text('Deploy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tab bar
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Compose YAML'),
|
||||
Tab(text: 'Environment Variables'),
|
||||
],
|
||||
),
|
||||
// Tab content
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
YamlEditor(stackId: stack.id),
|
||||
EnvVarsEditor(stackId: stack.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact container list for the docked bottom section.
|
||||
class _CompactContainerList extends ConsumerWidget {
|
||||
const _CompactContainerList({
|
||||
required this.containers,
|
||||
required this.stackId,
|
||||
});
|
||||
|
||||
final List<container_entity.Container> containers;
|
||||
final String stackId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final actions = ref.read(containerActionsProvider.notifier);
|
||||
|
||||
// Filter containers for this stack
|
||||
final stackContainers = containers
|
||||
.where((c) => c.stackId == stackId || c.stackName == stackId)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.dns, size: 18, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Containers (${stackContainers.length})',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => ref.invalidate(containersProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Container list
|
||||
Expanded(
|
||||
child: stackContainers.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No containers in this stack',
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: stackContainers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final container = stackContainers[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: ContainerStatusBadge.withStatus(
|
||||
state: container.state,
|
||||
status: container.status,
|
||||
),
|
||||
title: Text(
|
||||
container.name,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
container.image,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
itemBuilder: (context) => [
|
||||
if (container.canStart)
|
||||
const PopupMenuItem(
|
||||
value: 'start',
|
||||
child: Text('Start'),
|
||||
),
|
||||
if (container.canStop)
|
||||
const PopupMenuItem(
|
||||
value: 'stop',
|
||||
child: Text('Stop'),
|
||||
),
|
||||
if (container.canRestart)
|
||||
const PopupMenuItem(
|
||||
value: 'restart',
|
||||
child: Text('Restart'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'logs',
|
||||
child: Text('View Logs'),
|
||||
),
|
||||
],
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
actions.start(container.fullId);
|
||||
case 'stop':
|
||||
actions.stop(container.fullId);
|
||||
case 'restart':
|
||||
actions.restart(container.fullId);
|
||||
case 'logs':
|
||||
showContainerLogs(
|
||||
context,
|
||||
containerId: container.fullId,
|
||||
containerName: container.name,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/data/repositories/stack_repository_impl.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
||||
|
||||
part 'stacks_provider.g.dart';
|
||||
|
||||
/// Provides the list of all stacks.
|
||||
@riverpod
|
||||
Future<List<Stack>> stacks(Ref ref) async {
|
||||
final repository = ref.watch(stackRepositoryProvider);
|
||||
return repository.getStacks();
|
||||
}
|
||||
|
||||
/// Provides a single stack by ID.
|
||||
@riverpod
|
||||
Future<Stack> stack(Ref ref, String id) async {
|
||||
final repository = ref.watch(stackRepositoryProvider);
|
||||
return repository.getStack(id);
|
||||
}
|
||||
|
||||
/// Currently selected stack ID (null = all containers).
|
||||
@riverpod
|
||||
class SelectedStack extends _$SelectedStack {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void select(String? stackId) {
|
||||
state = stackId;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the YAML content for a stack.
|
||||
@riverpod
|
||||
Future<String> stackYaml(Ref ref, String stackId) async {
|
||||
final repository = ref.watch(stackRepositoryProvider);
|
||||
return repository.getStackYaml(stackId);
|
||||
}
|
||||
|
||||
/// Provides the environment variables for a stack.
|
||||
@riverpod
|
||||
Future<Map<String, String>> stackEnvVars(Ref ref, String stackId) async {
|
||||
final repository = ref.watch(stackRepositoryProvider);
|
||||
return repository.getStackEnvVars(stackId);
|
||||
}
|
||||
|
||||
/// Controller for stack configuration actions.
|
||||
@riverpod
|
||||
class StackConfigActions extends _$StackConfigActions {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncValue.data(null);
|
||||
|
||||
Future<void> saveYaml(String stackId, String yaml) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(stackRepositoryProvider);
|
||||
await repository.updateStackYaml(stackId, yaml);
|
||||
ref.invalidate(stackYamlProvider(stackId));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> saveEnvVars(String stackId, Map<String, String> envVars) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(stackRepositoryProvider);
|
||||
await repository.updateStackEnvVars(stackId, envVars);
|
||||
ref.invalidate(stackEnvVarsProvider(stackId));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deploy(String stackId) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(stackRepositoryProvider);
|
||||
await repository.deployStack(stackId);
|
||||
ref.invalidate(stacksProvider);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> rebuild(String stackId) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(stackRepositoryProvider);
|
||||
await repository.rebuildStack(stackId);
|
||||
ref.invalidate(stacksProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
|
||||
|
||||
/// Environment variables editor for stack configuration.
|
||||
class EnvVarsEditor extends ConsumerStatefulWidget {
|
||||
const EnvVarsEditor({
|
||||
super.key,
|
||||
required this.stackId,
|
||||
});
|
||||
|
||||
final String stackId;
|
||||
|
||||
@override
|
||||
ConsumerState<EnvVarsEditor> createState() => _EnvVarsEditorState();
|
||||
}
|
||||
|
||||
class _EnvVarsEditorState extends ConsumerState<EnvVarsEditor> {
|
||||
final List<_EnvVarEntry> _entries = [];
|
||||
Map<String, String> _originalEnvVars = {};
|
||||
bool _hasChanges = false;
|
||||
|
||||
void _initializeEntries(Map<String, String> envVars) {
|
||||
if (_originalEnvVars.isEmpty && envVars.isNotEmpty) {
|
||||
_originalEnvVars = Map.from(envVars);
|
||||
_entries.clear();
|
||||
for (final entry in envVars.entries) {
|
||||
_entries.add(_EnvVarEntry(
|
||||
keyController: TextEditingController(text: entry.key),
|
||||
valueController: TextEditingController(text: entry.value),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _checkChanges() {
|
||||
final currentMap = <String, String>{};
|
||||
for (final entry in _entries) {
|
||||
final key = entry.keyController.text.trim();
|
||||
if (key.isNotEmpty) {
|
||||
currentMap[key] = entry.valueController.text;
|
||||
}
|
||||
}
|
||||
|
||||
final hasChanges = !_mapsEqual(currentMap, _originalEnvVars);
|
||||
if (hasChanges != _hasChanges) {
|
||||
setState(() => _hasChanges = hasChanges);
|
||||
}
|
||||
}
|
||||
|
||||
bool _mapsEqual(Map<String, String> a, Map<String, String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final key in a.keys) {
|
||||
if (!b.containsKey(key) || a[key] != b[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _addEntry() {
|
||||
setState(() {
|
||||
_entries.add(_EnvVarEntry(
|
||||
keyController: TextEditingController(),
|
||||
valueController: TextEditingController(),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
void _removeEntry(int index) {
|
||||
setState(() {
|
||||
_entries[index].dispose();
|
||||
_entries.removeAt(index);
|
||||
_checkChanges();
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
final envVars = <String, String>{};
|
||||
for (final entry in _entries) {
|
||||
final key = entry.keyController.text.trim();
|
||||
if (key.isNotEmpty) {
|
||||
envVars[key] = entry.valueController.text;
|
||||
}
|
||||
}
|
||||
|
||||
ref
|
||||
.read(stackConfigActionsProvider.notifier)
|
||||
.saveEnvVars(widget.stackId, envVars);
|
||||
|
||||
setState(() {
|
||||
_originalEnvVars = Map.from(envVars);
|
||||
_hasChanges = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onReset() {
|
||||
for (final entry in _entries) {
|
||||
entry.dispose();
|
||||
}
|
||||
_entries.clear();
|
||||
|
||||
for (final entry in _originalEnvVars.entries) {
|
||||
_entries.add(_EnvVarEntry(
|
||||
keyController: TextEditingController(text: entry.key),
|
||||
valueController: TextEditingController(text: entry.value),
|
||||
));
|
||||
}
|
||||
|
||||
setState(() => _hasChanges = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final entry in _entries) {
|
||||
entry.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final envVarsAsync = ref.watch(stackEnvVarsProvider(widget.stackId));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final configState = ref.watch(stackConfigActionsProvider);
|
||||
final isLoading = configState.isLoading;
|
||||
|
||||
return envVarsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load environment variables',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
ref.invalidate(stackEnvVarsProvider(widget.stackId)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (envVars) {
|
||||
_initializeEntries(envVars);
|
||||
return Column(
|
||||
children: [
|
||||
// Toolbar
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'.env',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_hasChanges) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Modified',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Add variable',
|
||||
onPressed: _addEntry,
|
||||
),
|
||||
if (_hasChanges) ...[
|
||||
TextButton(
|
||||
onPressed: isLoading ? null : _onReset,
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: isLoading ? null : _onSave,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save, size: 18),
|
||||
label: const Text('Save'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Environment variables list
|
||||
Expanded(
|
||||
child: _entries.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No environment variables',
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _addEntry,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Variable'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _entries[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Key field
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: entry.keyController,
|
||||
onChanged: (_) => _checkChanges(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Key',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Equals sign
|
||||
Text(
|
||||
'=',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: colorScheme.outline,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Value field
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
controller: entry.valueController,
|
||||
onChanged: (_) => _checkChanges(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Value',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Delete button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _removeEntry(index),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to manage key-value pair controllers.
|
||||
class _EnvVarEntry {
|
||||
_EnvVarEntry({
|
||||
required this.keyController,
|
||||
required this.valueController,
|
||||
});
|
||||
|
||||
final TextEditingController keyController;
|
||||
final TextEditingController valueController;
|
||||
|
||||
void dispose() {
|
||||
keyController.dispose();
|
||||
valueController.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart' hide Stack;
|
||||
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
||||
|
||||
/// List tile for displaying a stack in the sidebar.
|
||||
class StackListTile extends StatelessWidget {
|
||||
const StackListTile({
|
||||
super.key,
|
||||
required this.stack,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
this.onStart,
|
||||
this.onStop,
|
||||
this.onRestart,
|
||||
});
|
||||
|
||||
final Stack stack;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onStart;
|
||||
final VoidCallback? onStop;
|
||||
final VoidCallback? onRestart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
selected: isSelected,
|
||||
selectedTileColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
leading: _buildStatusIndicator(context),
|
||||
title: Text(
|
||||
stack.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${stack.runningCount}/${stack.containerCount} containers',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: _buildActions(context),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIndicator(BuildContext context) {
|
||||
final color = switch (stack.status) {
|
||||
StackStatus.active when stack.isHealthy => Colors.green,
|
||||
StackStatus.active when stack.isPartial => Colors.orange,
|
||||
StackStatus.active => Colors.green,
|
||||
StackStatus.inactive => Theme.of(context).colorScheme.outline,
|
||||
StackStatus.error => Theme.of(context).colorScheme.error,
|
||||
StackStatus.unknown => Theme.of(context).colorScheme.outline,
|
||||
};
|
||||
|
||||
return Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
boxShadow: [
|
||||
if (stack.isHealthy)
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.4),
|
||||
blurRadius: 4,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildActions(BuildContext context) {
|
||||
if (onStart == null && onStop == null && onRestart == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
tooltip: 'Stack actions',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
onStart?.call();
|
||||
case 'stop':
|
||||
onStop?.call();
|
||||
case 'restart':
|
||||
onRestart?.call();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (onStart != null && !stack.isHealthy)
|
||||
const PopupMenuItem(
|
||||
value: 'start',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.play_arrow, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('Start'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onStop != null && stack.hasRunningContainers)
|
||||
const PopupMenuItem(
|
||||
value: 'stop',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.stop, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('Stop'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onRestart != null && stack.hasRunningContainers)
|
||||
const PopupMenuItem(
|
||||
value: 'restart',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.refresh, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('Restart'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
|
||||
import 'package:tatlock_ui/shared/components/code_editor/code_editor.dart';
|
||||
|
||||
/// YAML editor for stack compose configuration.
|
||||
class YamlEditor extends ConsumerStatefulWidget {
|
||||
const YamlEditor({
|
||||
super.key,
|
||||
required this.stackId,
|
||||
});
|
||||
|
||||
final String stackId;
|
||||
|
||||
@override
|
||||
ConsumerState<YamlEditor> createState() => _YamlEditorState();
|
||||
}
|
||||
|
||||
class _YamlEditorState extends ConsumerState<YamlEditor> {
|
||||
final _editorKey = GlobalKey<CodeEditorState>();
|
||||
bool _hasChanges = false;
|
||||
String _originalYaml = '';
|
||||
bool _initialized = false;
|
||||
|
||||
void _initializeEditor(String yaml) {
|
||||
if (!_initialized && yaml.isNotEmpty) {
|
||||
_originalYaml = yaml;
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
setState(() {
|
||||
_hasChanges = value != _originalYaml;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
final currentText = _editorKey.currentState?.text ?? '';
|
||||
ref
|
||||
.read(stackConfigActionsProvider.notifier)
|
||||
.saveYaml(widget.stackId, currentText);
|
||||
setState(() {
|
||||
_originalYaml = currentText;
|
||||
_hasChanges = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onReset() {
|
||||
_editorKey.currentState?.text = _originalYaml;
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final yamlAsync = ref.watch(stackYamlProvider(widget.stackId));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final configState = ref.watch(stackConfigActionsProvider);
|
||||
final isLoading = configState.isLoading;
|
||||
|
||||
return yamlAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load YAML',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => ref.invalidate(stackYamlProvider(widget.stackId)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (yaml) {
|
||||
_initializeEditor(yaml);
|
||||
return Column(
|
||||
children: [
|
||||
// Toolbar
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.code,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'docker-compose.yml',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_hasChanges) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Modified',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (_hasChanges) ...[
|
||||
TextButton(
|
||||
onPressed: isLoading ? null : _onReset,
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: isLoading ? null : _onSave,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save, size: 18),
|
||||
label: const Text('Save'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Editor with syntax highlighting
|
||||
Expanded(
|
||||
child: CodeEditor(
|
||||
key: _editorKey,
|
||||
language: CodeEditorLanguage.yaml,
|
||||
initialValue: yaml,
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/environment_model.dart';
|
||||
|
||||
part 'environment_datasource.g.dart';
|
||||
|
||||
/// Data source for environment data operations.
|
||||
///
|
||||
/// Fetches weather, forecast, sun times, and air quality from Core API.
|
||||
class EnvironmentDatasource {
|
||||
EnvironmentDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _basePath = '/tools/environment';
|
||||
|
||||
/// Gets current environment data.
|
||||
///
|
||||
/// Returns weather, forecast, sun times, and optionally air quality.
|
||||
Future<EnvironmentData> getEnvironment() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(_basePath);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Failed to fetch environment data');
|
||||
}
|
||||
|
||||
return EnvironmentData.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the environment datasource.
|
||||
@riverpod
|
||||
EnvironmentDatasource environmentDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
return EnvironmentDatasource(dio);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/news_model.dart';
|
||||
|
||||
part 'news_datasource.g.dart';
|
||||
|
||||
/// Data source for news data operations.
|
||||
///
|
||||
/// Fetches news headlines from Core API.
|
||||
class NewsDatasource {
|
||||
NewsDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _basePath = '/tools/news';
|
||||
|
||||
/// Gets current news headlines.
|
||||
///
|
||||
/// Returns headlines for the news ticker.
|
||||
Future<NewsData> getNews() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(_basePath);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Failed to fetch news data');
|
||||
}
|
||||
|
||||
return NewsData.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the news datasource.
|
||||
@riverpod
|
||||
NewsDatasource newsDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
return NewsDatasource(dio);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/data/models/quick_link_model.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
|
||||
|
||||
part 'quick_links_datasource.g.dart';
|
||||
|
||||
/// Data source for quick link operations.
|
||||
///
|
||||
/// Provides CRUD operations for Front Hall quick links via Core API
|
||||
/// dashboard/quick-links endpoint.
|
||||
class QuickLinksDatasource {
|
||||
QuickLinksDatasource(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _basePath = '/dashboard/quick-links';
|
||||
|
||||
/// Gets all quick links.
|
||||
///
|
||||
/// API returns: {"links": [...], "total": N}
|
||||
Future<List<QuickLink>> getQuickLinks() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(_basePath);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final links = data['links'] as List<dynamic>? ?? [];
|
||||
return links
|
||||
.map((json) => QuickLinkModel.fromJson(json as Map<String, dynamic>))
|
||||
.map((model) => model.toEntity())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Gets a single quick link by ID.
|
||||
Future<QuickLink> getQuickLink(int id) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('$_basePath/$id');
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Quick link not found: $id');
|
||||
}
|
||||
|
||||
return QuickLinkModel.fromJson(data).toEntity();
|
||||
}
|
||||
|
||||
/// Creates a new quick link.
|
||||
Future<QuickLink> createQuickLink(QuickLink link) async {
|
||||
final model = QuickLinkModel.fromEntity(link);
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
data: model.toJson(),
|
||||
);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Failed to create quick link');
|
||||
}
|
||||
|
||||
return QuickLinkModel.fromJson(data).toEntity();
|
||||
}
|
||||
|
||||
/// Updates an existing quick link.
|
||||
Future<QuickLink> updateQuickLink(QuickLink link) async {
|
||||
final model = QuickLinkModel.fromEntity(link);
|
||||
final id = int.tryParse(link.id) ?? 0;
|
||||
final response = await _dio.put<Map<String, dynamic>>(
|
||||
'$_basePath/$id',
|
||||
data: model.toJson(),
|
||||
);
|
||||
final data = response.data;
|
||||
|
||||
if (data == null) {
|
||||
throw Exception('Failed to update quick link');
|
||||
}
|
||||
|
||||
return QuickLinkModel.fromJson(data).toEntity();
|
||||
}
|
||||
|
||||
/// Deletes a quick link.
|
||||
Future<void> deleteQuickLink(int id) async {
|
||||
await _dio.delete<void>('$_basePath/$id');
|
||||
}
|
||||
|
||||
/// Reorders quick links by updating positions.
|
||||
///
|
||||
/// API expects: {"link_ids": [1, 2, 3]}
|
||||
Future<void> reorderQuickLinks(List<int> orderedIds) async {
|
||||
await _dio.post<void>(
|
||||
'$_basePath/reorder',
|
||||
data: {'link_ids': orderedIds},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the quick links datasource.
|
||||
@riverpod
|
||||
QuickLinksDatasource quickLinksDatasource(Ref ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
return QuickLinksDatasource(dio);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'environment_model.freezed.dart';
|
||||
part 'environment_model.g.dart';
|
||||
|
||||
/// Environment data response from Core API.
|
||||
/// Contains weather, forecast, sun times, and optionally air quality.
|
||||
@freezed
|
||||
sealed class EnvironmentData with _$EnvironmentData {
|
||||
const factory EnvironmentData({
|
||||
WeatherData? weather,
|
||||
List<ForecastDay>? forecast,
|
||||
@JsonKey(name: 'sun_times') SunTimesData? sunTimes,
|
||||
@JsonKey(name: 'air_quality') AirQualityData? airQuality,
|
||||
@JsonKey(name: 'updated_at') required DateTime updatedAt,
|
||||
String? user,
|
||||
}) = _EnvironmentData;
|
||||
|
||||
factory EnvironmentData.fromJson(Map<String, dynamic> json) =>
|
||||
_$EnvironmentDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Current weather conditions.
|
||||
@freezed
|
||||
sealed class WeatherData with _$WeatherData {
|
||||
const factory WeatherData({
|
||||
double? temperature,
|
||||
@JsonKey(name: 'feels_like') double? feelsLike,
|
||||
String? conditions,
|
||||
int? humidity,
|
||||
@JsonKey(name: 'wind_speed') double? windSpeed,
|
||||
@JsonKey(name: 'wind_direction') String? windDirection,
|
||||
double? pressure,
|
||||
double? visibility,
|
||||
@JsonKey(name: 'uv_index') double? uvIndex,
|
||||
String? location,
|
||||
String? icon,
|
||||
}) = _WeatherData;
|
||||
|
||||
factory WeatherData.fromJson(Map<String, dynamic> json) =>
|
||||
_$WeatherDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Single day forecast data.
|
||||
@freezed
|
||||
sealed class ForecastDay with _$ForecastDay {
|
||||
const factory ForecastDay({
|
||||
required String date,
|
||||
double? high,
|
||||
double? low,
|
||||
String? conditions,
|
||||
@JsonKey(name: 'precipitation_chance') int? precipitationChance,
|
||||
String? icon,
|
||||
}) = _ForecastDay;
|
||||
|
||||
factory ForecastDay.fromJson(Map<String, dynamic> json) =>
|
||||
_$ForecastDayFromJson(json);
|
||||
}
|
||||
|
||||
/// Sunrise and sunset times.
|
||||
@freezed
|
||||
sealed class SunTimesData with _$SunTimesData {
|
||||
const factory SunTimesData({
|
||||
DateTime? sunrise,
|
||||
DateTime? sunset,
|
||||
@JsonKey(name: 'daylight_minutes') int? daylightMinutes,
|
||||
@JsonKey(name: 'solar_noon') DateTime? solarNoon,
|
||||
DateTime? dawn,
|
||||
DateTime? dusk,
|
||||
}) = _SunTimesData;
|
||||
|
||||
factory SunTimesData.fromJson(Map<String, dynamic> json) =>
|
||||
_$SunTimesDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Air quality information.
|
||||
@freezed
|
||||
sealed class AirQualityData with _$AirQualityData {
|
||||
const factory AirQualityData({
|
||||
int? aqi,
|
||||
String? quality,
|
||||
double? pm25,
|
||||
double? pm10,
|
||||
double? o3,
|
||||
double? no2,
|
||||
String? location,
|
||||
}) = _AirQualityData;
|
||||
|
||||
factory AirQualityData.fromJson(Map<String, dynamic> json) =>
|
||||
_$AirQualityDataFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'news_model.freezed.dart';
|
||||
part 'news_model.g.dart';
|
||||
|
||||
/// News response from Core API.
|
||||
/// Contains headlines for the news ticker.
|
||||
@freezed
|
||||
sealed class NewsData with _$NewsData {
|
||||
const factory NewsData({
|
||||
required List<NewsHeadline> headlines,
|
||||
String? category,
|
||||
List<String>? sources,
|
||||
@JsonKey(name: 'updated_at') required DateTime updatedAt,
|
||||
String? user,
|
||||
}) = _NewsData;
|
||||
|
||||
factory NewsData.fromJson(Map<String, dynamic> json) =>
|
||||
_$NewsDataFromJson(json);
|
||||
}
|
||||
|
||||
/// Single news headline.
|
||||
@freezed
|
||||
sealed class NewsHeadline with _$NewsHeadline {
|
||||
const factory NewsHeadline({
|
||||
required String title,
|
||||
String? description,
|
||||
String? source,
|
||||
String? url,
|
||||
}) = _NewsHeadline;
|
||||
|
||||
factory NewsHeadline.fromJson(Map<String, dynamic> json) =>
|
||||
_$NewsHeadlineFromJson(json);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user