diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..d566284
--- /dev/null
+++ b/.air.toml
@@ -0,0 +1,15 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+ cmd = "go build -o ./tmp/main ./cmd/goappmon"
+ entrypoint = ["./tmp/main"]
+ include_ext = ["go", "html", "sql", "json"]
+ exclude_dir = ["tmp", "vendor", ".git"]
+ exclude_regex = ["_test\\.go"]
+
+[log]
+ time = true
+
+[misc]
+ clean_on_exit = true
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..55fe353
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,67 @@
+name: Bug report
+description: Report a reproducible problem or regression
+title: "[Bug]: "
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to report a bug.
+ Please fill in as much detail as possible so the issue can be reproduced quickly.
+ - type: textarea
+ id: summary
+ attributes:
+ label: Summary
+ description: Describe the problem in one or two sentences.
+ placeholder: What happened?
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ description: List the exact steps that trigger the issue.
+ placeholder: |
+ 1. ...
+ 2. ...
+ 3. ...
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ description: What did you expect to happen?
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ description: What actually happened?
+ validations:
+ required: true
+ - type: textarea
+ id: environment
+ attributes:
+ label: Environment
+ description: Share relevant runtime, OS, Go version, or deployment details.
+ placeholder: Go version, OS, package version, etc.
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs or screenshots
+ description: Paste logs, stack traces, or screenshots if they help explain the problem.
+ render: shell
+ - type: checkboxes
+ id: confirmations
+ attributes:
+ label: Confirmations
+ options:
+ - label: I have searched existing issues and docs.
+ required: true
+ - label: I can reproduce this issue consistently.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..92dac9b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Project documentation
+ url: https://github.com/phyowaiyan-dev/goappmon/tree/develop/docs
+ about: Review the project docs before opening a new issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..83a4927
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,35 @@
+name: Feature request
+description: Suggest an improvement or new capability
+title: "[Feature]: "
+labels:
+ - enhancement
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for sharing an idea.
+ Please describe the user problem and the outcome you want, not just the implementation.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem statement
+ description: What user problem should this feature solve?
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed solution
+ description: Describe the solution you would like to see.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ description: Share any other options you considered.
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional context
+ description: Add screenshots, references, or related issues.
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
new file mode 100644
index 0000000..e975c6c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -0,0 +1,22 @@
+name: Question
+description: Ask for help or clarification about the project
+title: "[Question]: "
+labels:
+ - question
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Use this template for support questions, usage clarification, or project guidance.
+ - type: textarea
+ id: question
+ attributes:
+ label: Your question
+ description: Ask your question clearly and include context.
+ validations:
+ required: true
+ - type: textarea
+ id: context
+ attributes:
+ label: Context
+ description: Include code, config, or links that help explain the question.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e596a42
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,78 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - develop
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ fmt:
+ name: Go Format
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.1"
+ check-latest: true
+
+ - name: Verify gofmt
+ run: |
+ if [ -n "$(gofmt -l .)" ]; then
+ echo "The following files need gofmt:"
+ gofmt -l .
+ exit 1
+ fi
+
+ lint:
+ name: GolangCI-Lint
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.1"
+ check-latest: true
+
+ - name: Run golangci-lint
+ uses: golangci/golangci-lint-action@v8
+ with:
+ version: v2.11.2
+ args: --timeout=5m
+
+ test:
+ name: Go Test
+ runs-on: ubuntu-latest
+ needs:
+ - fmt
+ - lint
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.1"
+ check-latest: true
+ cache: true
+
+ - name: Download modules
+ run: go mod download
+
+ - name: Run tests
+ run: go test ./...
+
+ - name: Build
+ run: go build ./...
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..3a251fa
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,40 @@
+name: Docs
+
+on:
+ push:
+ paths:
+ - "README.md"
+ - "docs/**"
+ - ".github/workflows/docs.yml"
+ pull_request:
+ paths:
+ - "README.md"
+ - "docs/**"
+ - ".github/workflows/docs.yml"
+ workflow_dispatch:
+
+jobs:
+ validate-docs:
+ name: Validate docs structure
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Check required docs files exist
+ run: |
+ test -f README.md
+ test -f docs/README.md
+ test -f docs/project-overview.md
+ test -f docs/tech-stack.md
+ test -f docs/architecture.md
+ test -f docs/development.md
+ test -f docs/testing.md
+ test -f docs/release.md
+ test -f docs/security.md
+ test -f docs/contributing.md
+ test -f docs/roadmap.md
+
+ - name: Print docs tree
+ run: find docs -maxdepth 1 -type f | sort
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..b8d6511
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,121 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ name: Build ${{ matrix.os }} / ${{ matrix.arch }}
+ runs-on: ${{ matrix.runner }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: linux
+ arch: amd64
+ runner: ubuntu-latest
+ artifact_name: goappmon-linux-amd64
+ archive_name: goappmon-linux-amd64.tar.gz
+ binary_name: goappmon
+ - os: linux
+ arch: arm64
+ runner: ubuntu-latest
+ artifact_name: goappmon-linux-arm64
+ archive_name: goappmon-linux-arm64.tar.gz
+ binary_name: goappmon
+ - os: darwin
+ arch: amd64
+ runner: macos-latest
+ artifact_name: goappmon-darwin-amd64
+ archive_name: goappmon-darwin-amd64.tar.gz
+ binary_name: goappmon
+ - os: darwin
+ arch: arm64
+ runner: macos-latest
+ artifact_name: goappmon-darwin-arm64
+ archive_name: goappmon-darwin-arm64.tar.gz
+ binary_name: goappmon
+ - os: windows
+ arch: amd64
+ runner: windows-latest
+ artifact_name: goappmon-windows-amd64
+ archive_name: goappmon-windows-amd64.zip
+ binary_name: goappmon.exe
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26.1"
+ check-latest: true
+ cache: true
+
+ - name: Download modules
+ run: go mod download
+
+ - name: Build binary
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p dist
+ GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "dist/${{ matrix.binary_name }}" ./cmd/goappmon
+
+ - name: Package Linux and macOS release
+ if: matrix.os != 'windows'
+ shell: bash
+ run: |
+ set -euo pipefail
+ tar -czf "dist/${{ matrix.archive_name }}" -C dist "${{ matrix.binary_name }}"
+
+ - name: Package Windows release
+ if: matrix.os == 'windows'
+ shell: pwsh
+ run: |
+ Compress-Archive -Path "dist/${{ matrix.binary_name }}" -DestinationPath "dist/${{ matrix.archive_name }}"
+
+ - name: Upload release artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact_name }}
+ path: dist/${{ matrix.archive_name }}
+ if-no-files-found: error
+ retention-days: 14
+
+ publish:
+ name: Publish GitHub Release
+ runs-on: ubuntu-latest
+ needs:
+ - build
+ if: startsWith(github.ref, 'refs/tags/')
+
+ steps:
+ - name: Download all release artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: release-assets
+
+ - name: Collect release files
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p dist
+ find release-assets -type f | while read -r file; do
+ cp "$file" dist/
+ done
+ ls -lah dist
+
+ - name: Publish release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: dist/*
+ generate_release_notes: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c3c3d95
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+storage/*.sqlite
+storage/*.sqlite-wal
+storage/*.sqlite-shm
+storage/*.sqlite-journal
+storage/*.db
+storage/*.db-wal
+storage/*.db-shm
+storage/*.db-journal
+storage/*.key
+storage/session.key
+tmp/
+*.log
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3d74ac6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## Unreleased
+
+- Initial repository scaffolding
+- Documentation foundation
+- GitHub issue templates and starter workflows
+
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..1cd0125
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,32 @@
+# Code of Conduct
+
+## Our Pledge
+
+We are committed to making this project a respectful, welcoming, and productive space for everyone.
+
+## Expected Behavior
+
+Please:
+
+- be respectful in reviews, issues, and discussions
+- focus on the code and the problem, not the person
+- give feedback that is specific and constructive
+- assume good intent unless there is clear evidence otherwise
+
+## Unacceptable Behavior
+
+The following are not acceptable:
+
+- harassment or discrimination
+- insulting or demeaning language
+- deliberate disruption of discussions or reviews
+- sharing private information without permission
+
+## Enforcement
+
+Maintainers may remove content or restrict participation when behavior does not meet this standard.
+
+## Scope
+
+This code of conduct applies to all project spaces, including issues, pull requests, documentation, and related community channels.
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2447385
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,60 @@
+# Contributing
+
+Thanks for your interest in `goappmon`.
+
+This project is intended to be maintained as a production-quality open-source repository. Contributions are welcome as long as they stay focused, testable, and aligned with the project documentation.
+
+## Before You Start
+
+Please read:
+
+- [Project overview](docs/project-overview.md)
+- [Tech stack](docs/tech-stack.md)
+- [Architecture](docs/architecture.md)
+- [Development guide](docs/development.md)
+- [Testing guide](docs/testing.md)
+- [Security policy](SECURITY.md)
+
+## Good Contributions
+
+- bug fixes
+- tests
+- documentation improvements
+- dependency or build fixes
+- code cleanup that improves maintainability
+
+## Contribution Workflow
+
+1. Open an issue or check existing work first.
+2. Keep the change narrow and easy to review.
+3. Add or update tests when behavior changes.
+4. Update docs when commands, configuration, or output changes.
+5. Run formatting and verification locally.
+6. Open a pull request with a clear description of the change.
+
+## Local Checks
+
+```bash
+go fmt ./...
+go test ./...
+go build ./...
+```
+
+## Pull Request Expectations
+
+- explain what changed and why
+- call out breaking changes clearly
+- include screenshots, logs, or examples when relevant
+- keep unrelated cleanup out of feature PRs unless necessary
+
+## Code Style
+
+- use standard Go formatting
+- keep functions and packages small when possible
+- prefer readability over cleverness
+- avoid introducing dependencies unless they solve a real problem
+
+## Documentation Rule
+
+If the code changes behavior, the docs should change too.
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0f09176
--- /dev/null
+++ b/README.md
@@ -0,0 +1,283 @@
+# goappmon
+
+`goappmon` is a lightweight application control center for mobile and web applications.
+
+It ships as a single Go binary with SQLite storage, Gin HTTP handlers, bcrypt-backed admin auth, server-rendered HTML, and JSON APIs for app status, version policy, and feature flags.
+
+## Start Here
+
+- [Project overview](docs/project-overview.md)
+- [Tech stack](docs/tech-stack.md)
+- [Architecture](docs/architecture.md)
+- [Development guide](docs/development.md)
+- [Deployment guide](docs/deployment.md)
+- [Testing guide](docs/testing.md)
+- [Release guide](docs/release.md)
+- [Security policy](docs/security.md)
+- [Contributing guide](docs/contributing.md)
+- [Roadmap](docs/roadmap.md)
+- [Docs index](docs/README.md)
+- [Contributor guide](CONTRIBUTING.md)
+- [Code of conduct](CODE_OF_CONDUCT.md)
+- [Security reporting](SECURITY.md)
+- [Changelog](CHANGELOG.md)
+
+## Current Status
+
+- Module path: `github.com/phyowaiyan-dev/goappmon`
+- Go version: `1.26.1`
+- License: MIT
+- Storage: `storage/goappmon.sqlite`
+- Server-rendered admin UI: yes
+- Public JSON APIs: yes
+- Source code: implemented MVP
+
+## Repository Contents
+
+- `go.mod` - module definition
+- `LICENSE` - MIT license
+- `README.md` - landing page and docs entry point
+- `docs/` - production-oriented project documentation
+- `CONTRIBUTING.md` - contributor workflow and standards
+- `CODE_OF_CONDUCT.md` - community behavior policy
+- `SECURITY.md` - vulnerability reporting and security contact guidance
+- `CHANGELOG.md` - release history placeholder
+- `internal/` - private implementation notes and work items
+
+## Build
+
+```bash
+go build ./...
+```
+
+## Run
+
+```bash
+go run ./cmd/goappmon
+```
+
+## GitHub Releases
+
+Every tagged release triggers `.github/workflows/release.yml` and publishes prebuilt archives for:
+
+- Linux `amd64`
+- Linux `arm64`
+- macOS `amd64`
+- macOS `arm64`
+- Windows `amd64`
+
+The archives contain a single runnable binary for the target platform, so production servers do not need to build from source.
+
+## Ubuntu Deployment
+
+`goappmon` is designed to run as a single Go binary on Ubuntu Server behind either Apache2 or Nginx.
+
+### 1. Install prerequisites
+
+```bash
+sudo apt update
+sudo apt install -y git build-essential ca-certificates
+```
+
+If you are deploying from a GitHub Release, download the Linux binary archive from the Releases page instead of building from source.
+
+```bash
+sudo mkdir -p /opt/goappmon
+curl -L -o /tmp/goappmon-linux-amd64.tar.gz "https://github.com/phyowaiyan-dev/goappmon/releases/latest/download/goappmon-linux-amd64.tar.gz"
+tar -xzf /tmp/goappmon-linux-amd64.tar.gz -C /tmp
+sudo install -m 755 /tmp/goappmon /opt/goappmon/goappmon
+```
+
+### 2. Create runtime directories
+
+The app stores SQLite data and its session secret under `storage/`.
+
+```bash
+sudo mkdir -p /opt/goappmon/storage
+sudo chown -R www-data:www-data /opt/goappmon
+```
+
+### 3. Run the app
+
+The default address is `:18180`.
+
+```bash
+GOAPPMON_ADDR=:18180 /opt/goappmon/goappmon
+```
+
+You can also override the defaults with environment variables:
+
+```bash
+export GOAPPMON_ADDR=:18180
+export GOAPPMON_DB_PATH=/opt/goappmon/storage/goappmon.sqlite
+export GOAPPMON_SESSION_KEY_PATH=/opt/goappmon/storage/session.key
+export GOAPPMON_LOG_LEVEL=info
+/opt/goappmon/goappmon
+```
+
+### 4. Point your domain to the server
+
+For your root domain and any subdomain, create `A` records that point to your Ubuntu server public IPv4 address.
+
+Example:
+
+- `example.com` -> `203.0.113.10`
+- `www.example.com` -> `203.0.113.10`
+- `admin.example.com` -> `203.0.113.10`
+
+If you use IPv6, add `AAAA` records too.
+
+Make sure your DNS provider has fully propagated before requesting SSL certificates.
+
+### 5. Open firewall ports
+
+Allow HTTP and HTTPS traffic to the server:
+
+```bash
+sudo ufw allow 80/tcp
+sudo ufw allow 443/tcp
+sudo ufw allow OpenSSH
+sudo ufw enable
+```
+
+### 6. Run as a systemd service
+
+Create `/etc/systemd/system/goappmon.service`:
+
+```ini
+[Unit]
+Description=GoAppMon
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=www-data
+Group=www-data
+WorkingDirectory=/opt/goappmon
+Environment=GOAPPMON_ADDR=127.0.0.1:18180
+Environment=GOAPPMON_DB_PATH=/opt/goappmon/storage/goappmon.sqlite
+Environment=GOAPPMON_SESSION_KEY_PATH=/opt/goappmon/storage/session.key
+Environment=GOAPPMON_LOG_LEVEL=info
+ExecStart=/opt/goappmon/goappmon
+Restart=always
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Then enable it:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now goappmon
+sudo systemctl status goappmon
+```
+
+### 7. Reverse proxy with Nginx
+
+Install Nginx:
+
+```bash
+sudo apt install -y nginx
+```
+
+Create `/etc/nginx/sites-available/goappmon`:
+
+```nginx
+server {
+ listen 80;
+ server_name example.com www.example.com admin.example.com;
+
+ location / {
+ proxy_pass http://127.0.0.1:18180;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+```
+
+Enable and reload:
+
+```bash
+sudo ln -s /etc/nginx/sites-available/goappmon /etc/nginx/sites-enabled/goappmon
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+### 8. Reverse proxy with Apache2
+
+Install Apache2 and enable required modules:
+
+```bash
+sudo apt install -y apache2
+sudo a2enmod proxy proxy_http headers rewrite ssl
+```
+
+Create a virtual host, for example `/etc/apache2/sites-available/goappmon.conf`:
+
+```apache
+
+ ServerName example.com
+ ServerAlias www.example.com admin.example.com
+
+ ProxyPreserveHost On
+ ProxyPass / http://127.0.0.1:18180/
+ ProxyPassReverse / http://127.0.0.1:18180/
+
+ RequestHeader set X-Forwarded-Proto "http"
+
+```
+
+Enable and reload:
+
+```bash
+sudo a2ensite goappmon
+sudo apache2ctl configtest
+sudo systemctl reload apache2
+```
+
+### 9. Add SSL with Certbot
+
+For Nginx:
+
+```bash
+sudo apt install -y certbot python3-certbot-nginx
+sudo certbot --nginx -d example.com -d www.example.com -d admin.example.com
+```
+
+For Apache2:
+
+```bash
+sudo apt install -y certbot python3-certbot-apache
+sudo certbot --apache -d example.com -d www.example.com -d admin.example.com
+```
+
+Certbot will renew automatically on Ubuntu through systemd timers. You can verify with:
+
+```bash
+sudo certbot renew --dry-run
+```
+
+### 10. Final checks
+
+After deployment, confirm:
+
+- `https://example.com` loads the app
+- `https://example.com/admin/login` opens the login page
+- `storage/goappmon.sqlite` exists and is writable by the service user
+- the reverse proxy forwards requests to `127.0.0.1:18180`
+
+## Deployment Notes
+
+- Keep the binary and `storage/` directory together on the server.
+- Do not expose the Go app directly to the internet when using Apache2 or Nginx.
+- Use HTTPS for all production traffic.
+
+## License
+
+This project is licensed under the MIT License. See [LICENSE](LICENSE) for the full text.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..ba3e281
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,30 @@
+# Security Policy
+
+## Supported Versions
+
+This repository is currently in its foundation stage, so there are no stable supported runtime releases yet.
+
+As the project matures, supported versions should be listed here.
+
+## Reporting a Vulnerability
+
+If you find a security issue:
+
+1. Do not open a public issue.
+2. Describe the problem clearly and include reproduction steps.
+3. Share any relevant logs, impact details, and affected versions.
+
+If a private reporting channel is added later, document it here.
+
+## Security Expectations
+
+- never commit secrets
+- avoid logging sensitive data
+- keep dependencies minimal and reviewed
+- validate all untrusted input
+- use explicit timeouts and cancellation for networked code
+
+## Responsible Disclosure
+
+Security fixes should be coordinated carefully so users can update before details are published.
+
diff --git a/cmd/goappmon/main.go b/cmd/goappmon/main.go
new file mode 100644
index 0000000..bc7095a
--- /dev/null
+++ b/cmd/goappmon/main.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/app"
+ "github.com/phyowaiyan-dev/goappmon/internal/config"
+)
+
+func main() {
+ cfg := config.Load()
+ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+ Level: cfg.LogLevel,
+ }))
+ slog.SetDefault(logger)
+
+ application, err := app.New(cfg, logger)
+ if err != nil {
+ logger.Error("failed to initialize application", "error", err)
+ os.Exit(1)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ if err := application.Run(ctx); err != nil {
+ logger.Error("application stopped with error", "error", err)
+ os.Exit(1)
+ }
+}
diff --git a/dev-notes.md b/dev-notes.md
new file mode 100644
index 0000000..9284120
--- /dev/null
+++ b/dev-notes.md
@@ -0,0 +1,4 @@
+# GoAppMon
+
+## Run Air on development
+- air -c .air.toml
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..3c86042
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,23 @@
+# Documentation Index
+
+This folder contains the project documentation for GoAppMon.
+
+## Guides
+
+- [Project overview](project-overview.md)
+- [Tech stack](tech-stack.md)
+- [Architecture](architecture.md)
+- [Development guide](development.md)
+- [Deployment guide](deployment.md)
+- [Testing guide](testing.md)
+- [Release guide](release.md)
+- [Roadmap](roadmap.md)
+
+## Community and Policy
+
+- [Contributing guide](contributing.md)
+- [Security policy](security.md)
+- [Code of conduct](../CODE_OF_CONDUCT.md)
+- [Root contributing guide](../CONTRIBUTING.md)
+- [Root security policy](../SECURITY.md)
+
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..276e87e
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,49 @@
+# Architecture
+
+## Target Shape
+
+The eventual architecture should keep the project easy to maintain and suitable for production use.
+
+Likely package boundaries:
+
+- `cmd/` for executable entry points
+- `internal/` for private implementation details
+- `pkg/` for reusable public packages, if needed
+- `examples/` for runnable usage samples
+- `tests/` for cross-package or integration-level tests
+
+## Design Goals
+
+- separate public API from implementation details
+- keep configuration explicit and testable
+- isolate I/O, networking, and external integrations
+- make failure modes visible and debuggable
+- avoid overengineering early
+
+## Suggested Layers
+
+### Application Layer
+
+Owns startup, wiring, and process lifecycle.
+
+### Domain or Core Layer
+
+Contains monitoring logic, policy decisions, and business rules.
+
+### Infrastructure Layer
+
+Contains adapters for logging, metrics export, storage, HTTP, filesystem, or third-party services.
+
+## Observability of the Tooling
+
+The project itself should be observable:
+
+- logs should be structured and actionable
+- failures should carry context
+- metrics or counters should be added where useful
+- errors should be wrapped with meaning
+
+## Architecture Rule
+
+Do not add a pattern, framework, or abstraction unless it helps with a real project need.
+
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..a601072
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,6 @@
+# Contributing
+
+For the full contributor workflow, please read the root-level [CONTRIBUTING.md](../CONTRIBUTING.md).
+
+This docs page exists so the documentation structure remains complete and the docs validation workflow can verify it.
+
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..d2f7ef5
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,201 @@
+# Deployment Guide
+
+## Overview
+
+`goappmon` runs as a single Go binary and stores its data in SQLite.
+The recommended production setup on Ubuntu is:
+
+- Go binary on the server
+- SQLite database and session key under `storage/`
+- systemd to manage the process
+- Apache2 or Nginx as a reverse proxy
+- Certbot for SSL certificates
+
+## Default Runtime Paths
+
+- database: `storage/goappmon.sqlite`
+- session key: `storage/session.key`
+- default address: `:18180`
+
+## Prepare the Server
+
+Install packages:
+
+```bash
+sudo apt update
+sudo apt install -y git build-essential ca-certificates
+```
+
+Install Go `1.26.1` or newer, then clone and build:
+
+```bash
+git clone https://github.com/phyowaiyan-dev/goappmon.git
+cd goappmon
+go build -o goappmon ./cmd/goappmon
+```
+
+If you prefer the production binary from GitHub Releases, download the Linux archive for your server architecture and install it into `/opt/goappmon/` instead of building from source.
+
+Create the storage directory:
+
+```bash
+mkdir -p storage
+chmod 755 storage
+```
+
+## DNS Setup
+
+Point your domain and subdomains to the Ubuntu server with `A` records.
+
+Example:
+
+- `example.com` -> server public IPv4
+- `www.example.com` -> server public IPv4
+- `admin.example.com` -> server public IPv4
+
+If your server has IPv6, add `AAAA` records too.
+
+Wait for DNS propagation before issuing SSL certificates.
+
+## Run with systemd
+
+Create `/etc/systemd/system/goappmon.service`:
+
+```ini
+[Unit]
+Description=GoAppMon
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=www-data
+Group=www-data
+WorkingDirectory=/opt/goappmon
+Environment=GOAPPMON_ADDR=127.0.0.1:18180
+Environment=GOAPPMON_DB_PATH=/opt/goappmon/storage/goappmon.sqlite
+Environment=GOAPPMON_SESSION_KEY_PATH=/opt/goappmon/storage/session.key
+Environment=GOAPPMON_LOG_LEVEL=info
+ExecStart=/opt/goappmon/goappmon
+Restart=always
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Enable it:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now goappmon
+sudo systemctl status goappmon
+```
+
+## Reverse Proxy with Nginx
+
+Install Nginx:
+
+```bash
+sudo apt install -y nginx
+```
+
+Example site file:
+
+```nginx
+server {
+ listen 80;
+ server_name example.com www.example.com admin.example.com;
+
+ location / {
+ proxy_pass http://127.0.0.1:18180;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+```
+
+Enable and reload:
+
+```bash
+sudo ln -s /etc/nginx/sites-available/goappmon /etc/nginx/sites-enabled/goappmon
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+## Reverse Proxy with Apache2
+
+Install Apache2:
+
+```bash
+sudo apt install -y apache2
+sudo a2enmod proxy proxy_http headers rewrite ssl
+```
+
+Example virtual host:
+
+```apache
+
+ ServerName example.com
+ ServerAlias www.example.com admin.example.com
+
+ ProxyPreserveHost On
+ ProxyPass / http://127.0.0.1:18180/
+ ProxyPassReverse / http://127.0.0.1:18180/
+
+ RequestHeader set X-Forwarded-Proto "http"
+
+```
+
+Enable and reload:
+
+```bash
+sudo a2ensite goappmon
+sudo apache2ctl configtest
+sudo systemctl reload apache2
+```
+
+## SSL with Certbot
+
+For Nginx:
+
+```bash
+sudo apt install -y certbot python3-certbot-nginx
+sudo certbot --nginx -d example.com -d www.example.com -d admin.example.com
+```
+
+For Apache2:
+
+```bash
+sudo apt install -y certbot python3-certbot-apache
+sudo certbot --apache -d example.com -d www.example.com -d admin.example.com
+```
+
+Test renewal:
+
+```bash
+sudo certbot renew --dry-run
+```
+
+## Firewall
+
+Allow required ports:
+
+```bash
+sudo ufw allow OpenSSH
+sudo ufw allow 80/tcp
+sudo ufw allow 443/tcp
+sudo ufw enable
+```
+
+## Post-Deployment Checklist
+
+- app starts without errors
+- `storage/goappmon.sqlite` is created
+- `storage/session.key` is created
+- reverse proxy points to `127.0.0.1:18180`
+- DNS `A` records resolve correctly
+- HTTPS certificate is active
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..6b57199
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,38 @@
+# Development Guide
+
+## Local Setup
+
+1. Install Go `1.26.1` or newer.
+2. Clone the repository.
+3. Run `go mod tidy` after dependencies or source files are added.
+
+## Common Commands
+
+```bash
+go fmt ./...
+go test ./...
+go build ./...
+```
+
+## Workflow
+
+1. Make the smallest useful change.
+2. Add or update tests.
+3. Keep docs in sync with behavior.
+4. Verify formatting and tests locally.
+5. Open a pull request with a clear description.
+
+## Code Expectations
+
+- prefer small, composable packages
+- keep public APIs minimal and stable
+- use descriptive names
+- avoid hidden global state when possible
+- handle errors explicitly
+
+## Branch Hygiene
+
+- use feature branches for all changes
+- keep commits focused
+- avoid mixing unrelated refactors into feature work
+
diff --git a/docs/project-overview.md b/docs/project-overview.md
new file mode 100644
index 0000000..3c03627
--- /dev/null
+++ b/docs/project-overview.md
@@ -0,0 +1,50 @@
+# Project Overview
+
+## Purpose
+
+`goappmon` is a production-oriented Go project for application monitoring and observability.
+
+It is currently implemented as a lightweight application control center for mobile and web apps.
+
+The project can grow into one or more of the following shapes:
+
+- a reusable Go library
+- a CLI for monitoring and diagnostics
+- an agent or service for health and telemetry collection
+- integrations for dashboards, alerting, or external observability platforms
+
+## Current State
+
+The repository currently contains the MVP application, docs, templates, and GitHub workflow scaffolding.
+
+Current facts:
+
+- module path: `github.com/phyowaiyan-dev/goappmon`
+- Go version: `1.26.1`
+- license: MIT
+- database: SQLite at `storage/goappmon.sqlite`
+- auth: bcrypt + secure cookie session
+- web UI: html/template with Tailwind CDN
+- public API: health, status, version, config, feature flags
+
+## Principles
+
+The project should be built with the following principles:
+
+- production safety first
+- clear defaults and minimal surprise
+- testability and maintainability
+- explicit configuration
+- good observability of the observability tooling itself
+- OSS-friendly contribution and release flow
+
+## Non-Goals
+
+The MVP does not include:
+
+- Docker packaging
+- Redis
+- PostgreSQL
+- .env-based configuration
+- a JavaScript frontend framework
+
diff --git a/docs/release.md b/docs/release.md
new file mode 100644
index 0000000..e9acfb2
--- /dev/null
+++ b/docs/release.md
@@ -0,0 +1,48 @@
+# Release Guide
+
+## Release Model
+
+The project should use predictable, documented releases.
+
+Recommended release inputs:
+
+- tagged versions
+- changelog entries
+- CI validation
+- documented breaking changes
+
+## Release Checklist
+
+- version is tagged consistently
+- tests pass
+- README and docs reflect the shipped behavior
+- security-sensitive changes are reviewed
+- example commands still work
+
+## Versioning
+
+Use a clear versioning policy once the codebase stabilizes.
+
+Common options:
+
+- semantic versioning
+- date-based releases
+- internal pre-release tags for early development
+
+## Artifacts to Publish
+
+Depending on the future project shape, releases may include:
+
+- Go module versions
+- CLI binaries for Linux, macOS, and Windows
+- release notes
+
+For `goappmon`, the GitHub release workflow builds runnable archives for:
+
+- Linux `amd64`
+- Linux `arm64`
+- macOS `amd64`
+- macOS `arm64`
+- Windows `amd64`
+
+That means production operators can download a release asset and run the binary directly on the target server without rebuilding from source.
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000..0f151dd
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,35 @@
+# Roadmap
+
+## Phase 0: Scaffold
+
+- module setup
+- license
+- documentation foundation
+
+## Phase 1: Core Project Shape
+
+- define the primary package structure
+- establish the public API or CLI entry point
+- add baseline tests
+- wire CI
+
+## Phase 2: Production Readiness
+
+- configuration handling
+- logging strategy
+- error handling conventions
+- release process
+- security documentation
+
+## Phase 3: OSS Maturity
+
+- contributing workflow
+- issue and PR templates
+- changelog practice
+- examples and guides
+- versioned releases
+
+## Roadmap Rule
+
+Do not promise features here until they are planned and actively being implemented.
+
diff --git a/docs/security.md b/docs/security.md
new file mode 100644
index 0000000..cc70736
--- /dev/null
+++ b/docs/security.md
@@ -0,0 +1,6 @@
+# Security
+
+For the full vulnerability reporting policy, please read the root-level [SECURITY.md](../SECURITY.md).
+
+This docs page exists so the documentation structure remains complete and the docs validation workflow can verify it.
+
diff --git a/docs/tech-stack.md b/docs/tech-stack.md
new file mode 100644
index 0000000..712a30c
--- /dev/null
+++ b/docs/tech-stack.md
@@ -0,0 +1,52 @@
+# Tech Stack
+
+## Baseline
+
+The project is currently defined as a Go module.
+
+- language: Go
+- minimum module declaration: `go 1.26.1`
+- package distribution: Go module
+- license: MIT
+
+## Recommended Production Stack
+
+These are the technologies and patterns the project can use as it grows.
+
+### Core Runtime
+
+- Go standard library for the base implementation
+- context-aware code for cancellation and deadlines
+- structured logging
+- configuration via environment variables and config files
+
+### Quality and Reliability
+
+- unit tests with `go test`
+- table-driven tests for behavior coverage
+- linting and formatting in CI
+- dependency review and vulnerability checks
+- release tags and changelog entries
+
+### Documentation
+
+- Markdown in `docs/`
+- example snippets in README and docs
+- architecture notes for major design decisions
+
+### Delivery and Maintenance
+
+- GitHub for source control and issues
+- GitHub Actions for CI
+- semantic or tag-based release flow
+- issue and pull request templates
+
+## Stack Decision Rule
+
+When adding new dependencies, prefer the smallest set that:
+
+- solves the problem clearly
+- has active maintenance
+- keeps the project easy to build and audit
+- does not lock the project into unnecessary complexity
+
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 0000000..b72e657
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,41 @@
+# Testing Guide
+
+## Testing Goals
+
+The project should be testable from the start.
+
+Testing should prove:
+
+- core behavior works as intended
+- regressions are caught early
+- error paths are handled
+- public examples remain valid
+
+## Minimum Test Baseline
+
+- unit tests for core packages
+- table-driven tests where they fit naturally
+- integration tests for external dependencies
+- smoke tests for executable entry points, once added
+
+## Commands
+
+```bash
+go test ./...
+```
+
+## Testing Standards
+
+- prefer deterministic tests
+- avoid real network calls unless explicitly testing integrations
+- isolate time, randomness, and filesystem access where practical
+- make failures easy to understand
+
+## Release Gate
+
+Do not ship a release until:
+
+- the full test suite passes
+- formatting and linting pass
+- documentation matches the current behavior
+
diff --git a/go.mod b/go.mod
index 83f8275..2aa7d89 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,55 @@
module github.com/phyowaiyan-dev/goappmon
go 1.26.1
+
+require (
+ github.com/gin-gonic/gin v1.12.0
+ github.com/shirou/gopsutil/v4 v4.26.5
+ golang.org/x/crypto v0.53.0
+ modernc.org/sqlite v1.53.0
+)
+
+require (
+ github.com/bytedance/gopkg v0.1.3 // indirect
+ github.com/bytedance/sonic v1.15.0 // indirect
+ github.com/bytedance/sonic/loader v0.5.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/ebitengine/purego v0.10.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-ole/go-ole v1.2.6 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.30.1 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/quic-go/quic-go v0.59.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/tklauser/go-sysconf v0.3.16 // indirect
+ github.com/tklauser/numcpus v0.11.0 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.3.1 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ golang.org/x/arch v0.22.0 // indirect
+ golang.org/x/net v0.55.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.38.0 // indirect
+ google.golang.org/protobuf v1.36.10 // indirect
+ modernc.org/libc v1.73.4 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..bfe548f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,155 @@
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
+github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
+github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
+github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
+github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
+github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
+github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
+github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
+golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
+golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
+golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
+modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
+modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
+modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
+modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
+modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/README.md b/internal/README.md
new file mode 100644
index 0000000..30d3e0e
--- /dev/null
+++ b/internal/README.md
@@ -0,0 +1,13 @@
+# Internal
+
+This directory is reserved for private implementation details that should not be treated as part of the public API surface.
+
+Use this folder for:
+
+- internal services and adapters
+- private helpers
+- implementation notes that guide the codebase
+- task lists that are only relevant to maintainers
+
+Anything that should be consumed by other packages should live elsewhere, such as `pkg/` if the project later exposes reusable public packages.
+
diff --git a/internal/TODO.md b/internal/TODO.md
new file mode 100644
index 0000000..8fe3ce9
--- /dev/null
+++ b/internal/TODO.md
@@ -0,0 +1,25 @@
+# Internal TODO
+
+This file tracks implementation work for the initial project build-out.
+
+## Priority 1
+
+- define the first public package or CLI entry point
+- decide the initial package structure under `internal/` and `pkg/`
+- add baseline unit tests
+- add a minimal example or smoke test
+
+## Priority 2
+
+- introduce configuration handling
+- add structured logging
+- define error wrapping and output conventions
+- document operational defaults
+
+## Priority 3
+
+- add integrations or exporters
+- add release automation
+- expand test coverage
+- document supported environments
+
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..de8cecd
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,345 @@
+package app
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/phyowaiyan-dev/goappmon/internal/config"
+ "github.com/phyowaiyan-dev/goappmon/internal/database"
+ "github.com/phyowaiyan-dev/goappmon/internal/handlers"
+ "github.com/phyowaiyan-dev/goappmon/internal/middleware"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+ "github.com/phyowaiyan-dev/goappmon/internal/services"
+ "github.com/phyowaiyan-dev/goappmon/web"
+)
+
+type App struct {
+ cfg config.Config
+ logger *slog.Logger
+ startedAt time.Time
+ db *sql.DB
+ router *gin.Engine
+ server *http.Server
+
+ renderer *templateRenderer
+
+ setupService *services.SetupService
+ authService *services.AuthService
+ statusService *services.StatusService
+ adminService *services.AdminService
+ adminRepo *repositories.AdminRepository
+}
+
+func New(cfg config.Config, logger *slog.Logger) (*App, error) {
+ if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o755); err != nil {
+ return nil, err
+ }
+ if err := os.MkdirAll(filepath.Dir(cfg.SessionKeyPath), 0o755); err != nil {
+ return nil, err
+ }
+
+ db, err := database.Open(cfg.DatabasePath)
+ if err != nil {
+ return nil, err
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+
+ if err := database.Migrate(ctx, db); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+
+ sessionSecret, err := loadSessionSecret(cfg.SessionKeyPath)
+ if err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+
+ adminRepo := repositories.NewAdminRepository(db)
+ settingRepo := repositories.NewSettingRepository(db)
+ flagRepo := repositories.NewFeatureFlagRepository(db)
+ setupService := services.NewSetupService(db)
+ if err := setupService.EnsureDefaultSettings(ctx); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+
+ authService := services.NewAuthService(adminRepo, sessionSecret, time.Duration(cfg.SessionDuration)*time.Second)
+ statusService := services.NewStatusService(settingRepo, flagRepo)
+ startedAt := time.Now().UTC()
+ adminService := services.NewAdminService(db, settingRepo, flagRepo, cfg.DatabasePath, startedAt)
+ renderer, err := newTemplateRenderer()
+ if err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+
+ gin.SetMode(gin.ReleaseMode)
+ router := gin.New()
+ router.Use(gin.Recovery())
+ router.Use(loggingMiddleware(logger))
+ router.Use(middleware.SetupRedirect(setupService))
+
+ app := &App{
+ cfg: cfg,
+ logger: logger,
+ startedAt: startedAt,
+ db: db,
+ router: router,
+ renderer: renderer,
+ setupService: setupService,
+ authService: authService,
+ statusService: statusService,
+ adminService: adminService,
+ adminRepo: adminRepo,
+ }
+ app.registerRoutes()
+ app.server = &http.Server{
+ Addr: cfg.Address,
+ Handler: app.router,
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ return app, nil
+}
+
+func (a *App) Run(ctx context.Context) error {
+ errCh := make(chan error, 1)
+ go func() {
+ a.logger.Info("starting server", "addr", a.cfg.Address)
+ errCh <- a.server.ListenAndServe()
+ }()
+
+ select {
+ case <-ctx.Done():
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := a.server.Shutdown(shutdownCtx); err != nil {
+ return err
+ }
+ return a.db.Close()
+ case err := <-errCh:
+ if err == nil || errors.Is(err, http.ErrServerClosed) {
+ return a.db.Close()
+ }
+ _ = a.db.Close()
+ return err
+ }
+}
+
+func (a *App) RenderPage(w http.ResponseWriter, page string, data any) error {
+ return a.renderer.RenderPage(w, page, data)
+}
+
+func (a *App) registerRoutes() {
+ publicHandler := handlers.NewPublicHandler(a.statusService)
+ setupHandler := handlers.NewSetupHandler(a.renderer, a.setupService)
+ authHandler := handlers.NewAuthHandler(a.renderer, a.authService, a.cfg.CookieName, time.Duration(a.cfg.SessionDuration)*time.Second)
+ adminHandler := handlers.NewAdminHandler(a.renderer, a.adminService)
+
+ a.router.GET("/", func(c *gin.Context) {
+ if cookie, err := c.Cookie(a.cfg.CookieName); err == nil && strings.TrimSpace(cookie) != "" {
+ c.Redirect(http.StatusFound, "/admin")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin/login")
+ })
+
+ a.router.GET("/health", publicHandler.Health)
+ a.router.GET("/api/status", publicHandler.Status)
+ a.router.GET("/api/version", publicHandler.Version)
+ a.router.GET("/api/config", publicHandler.Config)
+ a.router.GET("/api/feature-flags", publicHandler.FeatureFlags)
+
+ a.router.GET("/setup", setupHandler.Page)
+ a.router.POST("/setup", setupHandler.Submit)
+
+ a.router.GET("/admin/login", authHandler.LoginPage)
+ a.router.POST("/admin/login", authHandler.Login)
+ a.router.POST("/admin/logout", authHandler.Logout)
+
+ adminGroup := a.router.Group("/admin")
+ adminGroup.Use(middleware.RequireAuth(a.authService, a.adminRepo, a.cfg.CookieName))
+ adminGroup.GET("", adminHandler.Dashboard)
+ adminGroup.GET("/system-health", adminHandler.SystemHealthPanel)
+ adminGroup.GET("/postman-collection", adminHandler.DownloadPostmanCollection)
+ adminGroup.GET("/audit-logs", adminHandler.AuditLogsPage)
+ adminGroup.POST("/platforms", adminHandler.UpdatePlatforms)
+ adminGroup.POST("/settings/application", adminHandler.UpdateApplication)
+ adminGroup.POST("/settings/version", adminHandler.UpdateVersion)
+ adminGroup.POST("/version/:platform", adminHandler.PublishVersion)
+ adminGroup.POST("/version/:platform/delete", adminHandler.DeleteCurrentVersion)
+ adminGroup.POST("/settings/maintenance", adminHandler.UpdateMaintenance)
+ adminGroup.POST("/settings/banner", adminHandler.UpdateBanner)
+ adminGroup.POST("/feature-flags", adminHandler.CreateFlag)
+ adminGroup.POST("/feature-flags/:id", adminHandler.UpdateFlag)
+ adminGroup.POST("/feature-flags/:id/delete", adminHandler.DeleteFlag)
+}
+
+func loadSessionSecret(path string) ([]byte, error) {
+ if data, err := os.ReadFile(path); err == nil {
+ decoded, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
+ if decodeErr == nil && len(decoded) >= 32 {
+ return decoded, nil
+ }
+ }
+
+ secret := make([]byte, 32)
+ if _, err := rand.Read(secret); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString(secret)), 0o600); err != nil {
+ return nil, err
+ }
+ return secret, nil
+}
+
+func loggingMiddleware(logger *slog.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+ c.Next()
+ logger.Info("request",
+ "method", c.Request.Method,
+ "path", c.Request.URL.Path,
+ "status", c.Writer.Status(),
+ "latency", time.Since(start).String(),
+ "ip", c.ClientIP(),
+ )
+ }
+}
+
+type templateRenderer struct {
+ baseTemplates *template.Template
+ fragmentTemplates *template.Template
+}
+
+func newTemplateRenderer() (*templateRenderer, error) {
+ funcMap := templateFuncs()
+ baseTemplates, err := template.New("base").Funcs(funcMap).ParseFS(web.TemplatesFS,
+ "templates/layouts/*.html",
+ "templates/components/*.html",
+ )
+ if err != nil {
+ return nil, err
+ }
+ fragmentTemplates, err := template.New("fragments").Funcs(funcMap).ParseFS(web.TemplatesFS,
+ "templates/components/*.html",
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &templateRenderer{
+ baseTemplates: baseTemplates,
+ fragmentTemplates: fragmentTemplates,
+ }, nil
+}
+
+func (r *templateRenderer) RenderPage(w http.ResponseWriter, page string, data any) error {
+ tpl, err := r.baseTemplates.Clone()
+ if err != nil {
+ return fmt.Errorf("clone base templates: %w", err)
+ }
+ if _, err := tpl.ParseFS(web.TemplatesFS, "templates/pages/"+page); err != nil {
+ return fmt.Errorf("parse template %s: %w", page, err)
+ }
+ if err := tpl.ExecuteTemplate(w, "base", data); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (r *templateRenderer) RenderFragment(w http.ResponseWriter, fragment string, data any) error {
+ tpl, err := r.fragmentTemplates.Clone()
+ if err != nil {
+ return fmt.Errorf("clone fragment templates: %w", err)
+ }
+ if err := tpl.ExecuteTemplate(w, fragment, data); err != nil {
+ return err
+ }
+ return nil
+}
+
+func templateFuncs() template.FuncMap {
+ return template.FuncMap{
+ "humanTime": func(t time.Time) string {
+ if t.IsZero() {
+ return "-"
+ }
+ now := time.Now()
+ localTime := t.In(now.Location())
+ now = now.In(now.Location())
+
+ if localTime.After(now) {
+ return localTime.Format("Jan 2, 2006 3:04 PM")
+ }
+
+ diff := now.Sub(localTime)
+ switch {
+ case diff < time.Minute:
+ return "just now"
+ case diff < time.Hour:
+ minutes := int(diff.Minutes())
+ if minutes == 1 {
+ return "1 minute ago"
+ }
+ return fmt.Sprintf("%d minutes ago", minutes)
+ case diff < 24*time.Hour && sameDay(now, localTime):
+ hours := int(diff.Hours())
+ if hours == 1 {
+ return "1 hour ago"
+ }
+ return fmt.Sprintf("%d hours ago", hours)
+ case diff < 48*time.Hour:
+ return "Yesterday at " + localTime.Format("3:04 PM")
+ case diff < 7*24*time.Hour:
+ return localTime.Format("Mon at 3:04 PM")
+ default:
+ return localTime.Format("Jan 2, 2006 3:04 PM")
+ }
+ },
+ "appName": func() string {
+ return config.AppName
+ },
+ "appVersion": func() string {
+ return config.AppVersion
+ },
+ "yearNow": func() int {
+ return time.Now().Year()
+ },
+ "boolLabel": func(v bool) string {
+ if v {
+ return "Enabled"
+ }
+ return "Disabled"
+ },
+ "usageTone": func(v float64) string {
+ switch {
+ case v >= 85:
+ return "error"
+ case v >= 70:
+ return "warning"
+ default:
+ return "success"
+ }
+ },
+ }
+}
+
+func sameDay(a, b time.Time) bool {
+ ay, am, ad := a.Date()
+ by, bm, bd := b.Date()
+ return ay == by && am == bm && ad == bd
+}
diff --git a/internal/app/full_flow_test.go b/internal/app/full_flow_test.go
new file mode 100644
index 0000000..dd716c1
--- /dev/null
+++ b/internal/app/full_flow_test.go
@@ -0,0 +1,223 @@
+package app
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestFullHTTPFlow(t *testing.T) {
+ app := newTestApp(t)
+
+ // Setup wizard page and validation.
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/setup", nil)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Create admin account") {
+ t.Fatalf("unexpected setup page response: %d", rec.Code)
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodPost, "/setup", strings.NewReader("admin_name=&admin_email=&password=&app_name="))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "All fields are required") {
+ t.Fatalf("expected setup validation error, got %d %q", rec.Code, rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ form := url.Values{}
+ form.Set("admin_name", "Admin")
+ form.Set("admin_email", "admin@example.com")
+ form.Set("password", "secret123")
+ form.Set("app_name", "GoAppMon")
+ req = httptest.NewRequest(http.MethodPost, "/setup", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/login" {
+ t.Fatalf("expected setup redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+
+ // Login page and authentication.
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Sign in") {
+ t.Fatalf("unexpected login page response: %d", rec.Code)
+ }
+
+ rec = httptest.NewRecorder()
+ form = url.Values{}
+ form.Set("email", "admin@example.com")
+ form.Set("password", "wrong")
+ req = httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Invalid email or password") {
+ t.Fatalf("expected login error, got %d %q", rec.Code, rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ form.Set("password", "secret123")
+ req = httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin" {
+ t.Fatalf("expected login redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+
+ sessionCookie := rec.Result().Cookies()[0]
+
+ // Authenticated dashboard.
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/admin", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Admin Dashboard") {
+ t.Fatalf("unexpected dashboard response: %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "See All logs") {
+ t.Fatalf("expected audit log link in dashboard, got %q", rec.Body.String())
+ }
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/admin/system-health", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Server and runtime overview") {
+ t.Fatalf("unexpected system health fragment response: %d %q", rec.Code, rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/admin/postman-collection", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected postman download response, got %d", rec.Code)
+ }
+ if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "GoAppMon.postman_collection.json") {
+ t.Fatalf("expected postman attachment header, got %q", got)
+ }
+ if !strings.Contains(rec.Body.String(), "\"Health\"") || !strings.Contains(rec.Body.String(), "\"Admin Login\"") {
+ t.Fatalf("expected postman collection payload, got %q", rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/admin/audit-logs", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Audit Log") {
+ t.Fatalf("unexpected audit logs page response: %d %q", rec.Code, rec.Body.String())
+ }
+
+ // Login page should redirect when already authenticated.
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin" {
+ t.Fatalf("expected login redirect for authenticated user, got %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+
+ // Admin updates and feature flag CRUD.
+ post := func(path string, values url.Values) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ return rec
+ }
+
+ if rec := post("/admin/settings/application", url.Values{"app_name": {"GoAppMon Plus"}, "api_url": {"https://api.example.com"}}); rec.Code != http.StatusFound {
+ t.Fatalf("expected application update redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/settings/version", url.Values{
+ "android_latest_version": {"2.0.0"},
+ "android_min_version": {"1.5.0"},
+ "android_force_update": {"true"},
+ "ios_latest_version": {"2.1.0"},
+ "ios_min_version": {"1.6.0"},
+ "ios_force_update": {"false"},
+ }); rec.Code != http.StatusFound {
+ t.Fatalf("expected version update redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/settings/maintenance", url.Values{"maintenance_mode": {"true"}, "maintenance_message": {"maintenance"}}); rec.Code != http.StatusFound {
+ t.Fatalf("expected maintenance redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/settings/banner", url.Values{"banner_enabled": {"true"}, "banner_message": {"banner"}}); rec.Code != http.StatusFound {
+ t.Fatalf("expected banner redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/feature-flags", url.Values{"key": {"chat"}, "enabled": {"true"}}); rec.Code != http.StatusFound {
+ t.Fatalf("expected create flag redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/feature-flags", url.Values{"key": {"payment"}, "enabled": {"false"}}); rec.Code != http.StatusFound {
+ t.Fatalf("expected create second flag redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/version/android", url.Values{
+ "latest_version": {"3.0.0"},
+ "minimum_version": {"2.5.0"},
+ "force_update": {"false"},
+ "release_notes": {"android v3"},
+ }); rec.Code != http.StatusFound {
+ t.Fatalf("expected android publish redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/version/ios", url.Values{
+ "latest_version": {"3.1.0"},
+ "minimum_version": {"2.6.0"},
+ "force_update": {"true"},
+ "release_notes": {"ios v3"},
+ }); rec.Code != http.StatusFound {
+ t.Fatalf("expected ios publish redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/version/android/delete", url.Values{}); rec.Code != http.StatusFound {
+ t.Fatalf("expected android delete redirect, got %d", rec.Code)
+ }
+ if rec := post("/admin/version/ios/delete", url.Values{}); rec.Code != http.StatusFound {
+ t.Fatalf("expected ios delete redirect, got %d", rec.Code)
+ }
+
+ // Verify dashboard shows success notice and updated content.
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/admin?success=application_updated", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Application settings updated.") {
+ t.Fatalf("expected dashboard notice, got %d %q", rec.Code, rec.Body.String())
+ }
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/admin?success=version_deleted", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Version release deleted.") {
+ t.Fatalf("expected version deleted notice, got %d %q", rec.Code, rec.Body.String())
+ }
+
+ // Feature flag update/delete.
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodPost, "/admin/feature-flags/1", strings.NewReader(url.Values{"key": {"chat-v2"}, "enabled": {"false"}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound {
+ t.Fatalf("expected update flag redirect, got %d", rec.Code)
+ }
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodPost, "/admin/feature-flags/1/delete", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound {
+ t.Fatalf("expected delete flag redirect, got %d", rec.Code)
+ }
+
+ // Logout clears session.
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodPost, "/admin/logout", nil)
+ req.AddCookie(sessionCookie)
+ app.router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/login" {
+ t.Fatalf("expected logout redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+}
diff --git a/internal/app/public_api_test.go b/internal/app/public_api_test.go
new file mode 100644
index 0000000..58bdcf1
--- /dev/null
+++ b/internal/app/public_api_test.go
@@ -0,0 +1,145 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/config"
+ "github.com/phyowaiyan-dev/goappmon/internal/services"
+)
+
+func newTestApp(t *testing.T) *App {
+ t.Helper()
+
+ dir := t.TempDir()
+ cfg := config.Config{
+ Address: ":0",
+ DatabasePath: filepath.Join(dir, "goappmon.sqlite"),
+ SessionKeyPath: filepath.Join(dir, "session.key"),
+ LogLevel: slog.LevelError,
+ Environment: "test",
+ CookieName: "goappmon_session",
+ SessionDuration: 60 * 60,
+ }
+ logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
+ app, err := New(cfg, logger)
+ if err != nil {
+ t.Fatalf("new app: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = app.db.Close()
+ })
+ return app
+}
+
+func TestPublicRoutesRedirectBeforeSetup(t *testing.T) {
+ app := newTestApp(t)
+
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ rr := httptest.NewRecorder()
+ app.router.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusFound {
+ t.Fatalf("expected redirect, got %d", rr.Code)
+ }
+ if location := rr.Header().Get("Location"); location != "/setup" {
+ t.Fatalf("expected redirect to /setup, got %q", location)
+ }
+}
+
+func TestPublicAPIsAfterSetup(t *testing.T) {
+ app := newTestApp(t)
+ ctx := context.Background()
+
+ if err := app.setupService.CreateInitialSetup(ctx, "Admin", "admin@example.com", "secret123", "GoAppMon"); err != nil {
+ t.Fatalf("create setup: %v", err)
+ }
+ if err := app.adminService.UpdateApplication(ctx, services.ActionMeta{}, "GoAppMon Plus", "https://api.example.com"); err != nil {
+ t.Fatalf("update application: %v", err)
+ }
+ if err := app.adminService.PublishVersion(ctx, services.ActionMeta{}, "android", "2.0.0", "1.5.0", true, "android release"); err != nil {
+ t.Fatalf("publish android version: %v", err)
+ }
+ if err := app.adminService.PublishVersion(ctx, services.ActionMeta{}, "ios", "2.1.0", "1.6.0", false, "ios release"); err != nil {
+ t.Fatalf("publish ios version: %v", err)
+ }
+ if err := app.adminService.UpdateMaintenance(ctx, services.ActionMeta{}, true, "maintenance"); err != nil {
+ t.Fatalf("update maintenance: %v", err)
+ }
+ if err := app.adminService.UpdateBanner(ctx, services.ActionMeta{}, true, "banner"); err != nil {
+ t.Fatalf("update banner: %v", err)
+ }
+ if err := app.adminService.CreateFlag(ctx, services.ActionMeta{}, "chat", true); err != nil {
+ t.Fatalf("create flag: %v", err)
+ }
+ if err := app.adminService.CreateFlag(ctx, services.ActionMeta{}, "payment", false); err != nil {
+ t.Fatalf("create flag: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ path string
+ want map[string]any
+ }{
+ {name: "health", path: "/health", want: map[string]any{"status": "ok"}},
+ {name: "status", path: "/api/status", want: map[string]any{"maintenance_mode": true, "maintenance_message": "maintenance", "banner_enabled": true, "banner_message": "banner"}},
+ {name: "version", path: "/api/version", want: map[string]any{"android": map[string]any{"latest_version": "2.0.0", "minimum_version": "1.5.0", "force_update": true}, "ios": map[string]any{"latest_version": "2.1.0", "minimum_version": "1.6.0", "force_update": false}}},
+ {name: "config", path: "/api/config", want: map[string]any{"app_name": "GoAppMon Plus", "api_url": "https://api.example.com"}},
+ {name: "flags", path: "/api/feature-flags", want: map[string]any{"chat": true, "payment": false}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
+ rr := httptest.NewRecorder()
+ app.router.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+
+ var got map[string]any
+ if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+
+ assertJSONSubset(t, got, tc.want)
+ })
+ }
+}
+
+func assertJSONSubset(t *testing.T, got, want map[string]any) {
+ t.Helper()
+ for key, wantValue := range want {
+ gotValue, ok := got[key]
+ if !ok {
+ t.Fatalf("missing key %q in %#v", key, got)
+ }
+ switch wantTyped := wantValue.(type) {
+ case map[string]any:
+ gotTyped, ok := gotValue.(map[string]any)
+ if !ok {
+ t.Fatalf("key %q expected object, got %#v", key, gotValue)
+ }
+ assertJSONSubset(t, gotTyped, wantTyped)
+ case bool:
+ gotBool, ok := gotValue.(bool)
+ if !ok || gotBool != wantTyped {
+ t.Fatalf("key %q expected %v, got %#v", key, wantTyped, gotValue)
+ }
+ case string:
+ gotString, ok := gotValue.(string)
+ if !ok || gotString != wantTyped {
+ t.Fatalf("key %q expected %q, got %#v", key, wantTyped, gotValue)
+ }
+ default:
+ t.Fatalf("unsupported type %T", wantValue)
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..c8936b2
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,49 @@
+package config
+
+import (
+ "log/slog"
+ "os"
+ "strings"
+)
+
+type Config struct {
+ Address string
+ DatabasePath string
+ SessionKeyPath string
+ LogLevel slog.Level
+ Environment string
+ CookieName string
+ SessionDuration int64
+}
+
+func Load() Config {
+ return Config{
+ Address: getEnv("GOAPPMON_ADDR", ":18180"),
+ DatabasePath: getEnv("GOAPPMON_DB_PATH", "storage/goappmon.sqlite"),
+ SessionKeyPath: getEnv("GOAPPMON_SESSION_KEY_PATH", "storage/session.key"),
+ LogLevel: parseLogLevel(getEnv("GOAPPMON_LOG_LEVEL", "info")),
+ Environment: getEnv("GOAPPMON_ENV", "development"),
+ CookieName: getEnv("GOAPPMON_COOKIE_NAME", "goappmon_session"),
+ SessionDuration: 60 * 60 * 24 * 7,
+ }
+}
+
+func getEnv(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func parseLogLevel(value string) slog.Level {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "debug":
+ return slog.LevelDebug
+ case "warn", "warning":
+ return slog.LevelWarn
+ case "error":
+ return slog.LevelError
+ default:
+ return slog.LevelInfo
+ }
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..ad20baf
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,49 @@
+package config
+
+import "testing"
+
+func TestLoadUsesEnvironmentOverrides(t *testing.T) {
+ t.Setenv("GOAPPMON_ADDR", "127.0.0.1:9090")
+ t.Setenv("GOAPPMON_DB_PATH", "/tmp/goappmon.db")
+ t.Setenv("GOAPPMON_SESSION_KEY_PATH", "/tmp/session.key")
+ t.Setenv("GOAPPMON_LOG_LEVEL", "warning")
+ t.Setenv("GOAPPMON_ENV", "production")
+ t.Setenv("GOAPPMON_COOKIE_NAME", "custom_session")
+
+ cfg := Load()
+ if cfg.Address != "127.0.0.1:9090" {
+ t.Fatalf("unexpected address: %s", cfg.Address)
+ }
+ if cfg.DatabasePath != "/tmp/goappmon.db" || cfg.SessionKeyPath != "/tmp/session.key" {
+ t.Fatalf("unexpected paths: %+v", cfg)
+ }
+ if cfg.Environment != "production" || cfg.CookieName != "custom_session" {
+ t.Fatalf("unexpected config: %+v", cfg)
+ }
+ if cfg.LogLevel.String() != "WARN" {
+ t.Fatalf("unexpected log level: %s", cfg.LogLevel.String())
+ }
+}
+
+func TestLoadUsesDefaults(t *testing.T) {
+ t.Setenv("GOAPPMON_ADDR", "")
+ t.Setenv("GOAPPMON_DB_PATH", "")
+ t.Setenv("GOAPPMON_SESSION_KEY_PATH", "")
+ t.Setenv("GOAPPMON_LOG_LEVEL", "")
+ t.Setenv("GOAPPMON_ENV", "")
+ t.Setenv("GOAPPMON_COOKIE_NAME", "")
+
+ cfg := Load()
+ if cfg.Address != ":18180" {
+ t.Fatalf("unexpected default address: %s", cfg.Address)
+ }
+ if cfg.DatabasePath != "storage/goappmon.sqlite" || cfg.SessionKeyPath != "storage/session.key" {
+ t.Fatalf("unexpected default paths: %+v", cfg)
+ }
+ if cfg.Environment != "development" || cfg.CookieName != "goappmon_session" {
+ t.Fatalf("unexpected defaults: %+v", cfg)
+ }
+ if cfg.LogLevel.String() != "INFO" {
+ t.Fatalf("unexpected default log level: %s", cfg.LogLevel.String())
+ }
+}
diff --git a/internal/config/constants.go b/internal/config/constants.go
new file mode 100644
index 0000000..d7f33dd
--- /dev/null
+++ b/internal/config/constants.go
@@ -0,0 +1,6 @@
+package config
+
+const (
+ AppName = "GoAppMon"
+ AppVersion = "v0.0.1beta"
+)
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
new file mode 100644
index 0000000..9b10808
--- /dev/null
+++ b/internal/database/migrations.go
@@ -0,0 +1,87 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "embed"
+ "fmt"
+ "strings"
+)
+
+//go:embed migrations.sql
+var migrationsFS embed.FS
+
+func Migrate(ctx context.Context, db *sql.DB) error {
+ content, err := migrationsFS.ReadFile("migrations.sql")
+ if err != nil {
+ return err
+ }
+
+ for _, statement := range splitStatements(string(content)) {
+ if _, err := db.ExecContext(ctx, statement); err != nil {
+ return fmt.Errorf("run migration: %w", err)
+ }
+ }
+ if err := ensureSettingsColumns(ctx, db); err != nil {
+ return err
+ }
+ return nil
+}
+
+func ensureSettingsColumns(ctx context.Context, db *sql.DB) error {
+ columns, err := tableColumns(ctx, db, "settings")
+ if err != nil {
+ return err
+ }
+ required := map[string]string{
+ "android_enabled": "INTEGER NOT NULL DEFAULT 1",
+ "ios_enabled": "INTEGER NOT NULL DEFAULT 1",
+ }
+ for name, ddl := range required {
+ if _, ok := columns[name]; ok {
+ continue
+ }
+ if _, err := db.ExecContext(ctx, fmt.Sprintf("ALTER TABLE settings ADD COLUMN %s %s", name, ddl)); err != nil {
+ return fmt.Errorf("add settings column %s: %w", name, err)
+ }
+ }
+ return nil
+}
+
+func tableColumns(ctx context.Context, db *sql.DB, table string) (map[string]struct{}, error) {
+ rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ columns := make(map[string]struct{})
+ for rows.Next() {
+ var cid int
+ var name, colType string
+ var notNull int
+ var defaultValue sql.NullString
+ var pk int
+ if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil {
+ return nil, err
+ }
+ columns[name] = struct{}{}
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return columns, nil
+}
+
+func splitStatements(content string) []string {
+ parts := strings.Split(content, ";")
+ statements := make([]string, 0, len(parts))
+ for _, part := range parts {
+ statement := strings.TrimSpace(part)
+ if statement == "" {
+ continue
+ }
+ statements = append(statements, statement)
+ }
+ return statements
+}
diff --git a/internal/database/migrations.sql b/internal/database/migrations.sql
new file mode 100644
index 0000000..46cf36a
--- /dev/null
+++ b/internal/database/migrations.sql
@@ -0,0 +1,74 @@
+CREATE TABLE IF NOT EXISTS schema_migrations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ applied_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS admins (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS settings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ app_name TEXT NOT NULL,
+ android_enabled INTEGER NOT NULL DEFAULT 1,
+ android_latest_version TEXT NOT NULL DEFAULT '1.0.0',
+ android_min_version TEXT NOT NULL DEFAULT '1.0.0',
+ android_force_update INTEGER NOT NULL DEFAULT 0,
+ ios_enabled INTEGER NOT NULL DEFAULT 1,
+ ios_latest_version TEXT NOT NULL DEFAULT '1.0.0',
+ ios_min_version TEXT NOT NULL DEFAULT '1.0.0',
+ ios_force_update INTEGER NOT NULL DEFAULT 0,
+ maintenance_mode INTEGER NOT NULL DEFAULT 0,
+ maintenance_message TEXT NOT NULL DEFAULT '',
+ banner_enabled INTEGER NOT NULL DEFAULT 0,
+ banner_message TEXT NOT NULL DEFAULT '',
+ api_url TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS feature_flags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT NOT NULL UNIQUE,
+ enabled INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS version_releases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ platform TEXT NOT NULL,
+ latest_version TEXT NOT NULL,
+ minimum_version TEXT NOT NULL,
+ force_update INTEGER NOT NULL DEFAULT 0,
+ release_notes TEXT NOT NULL DEFAULT '',
+ created_by_admin_id INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS state_changes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kind TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ message TEXT NOT NULL DEFAULT '',
+ created_by_admin_id INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS audit_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ actor_admin_id INTEGER NOT NULL DEFAULT 0,
+ action TEXT NOT NULL,
+ entity_type TEXT NOT NULL,
+ entity_id TEXT NOT NULL DEFAULT '',
+ before_json TEXT NOT NULL DEFAULT '',
+ after_json TEXT NOT NULL DEFAULT '',
+ ip TEXT NOT NULL DEFAULT '',
+ user_agent TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL
+);
diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go
new file mode 100644
index 0000000..ea358a2
--- /dev/null
+++ b/internal/database/sqlite.go
@@ -0,0 +1,29 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ _ "modernc.org/sqlite"
+)
+
+func Open(path string) (*sql.DB, error) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return nil, err
+ }
+
+ db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path))
+ if err != nil {
+ return nil, err
+ }
+
+ if err := db.PingContext(context.Background()); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+
+ return db, nil
+}
diff --git a/internal/handlers/admin_handler.go b/internal/handlers/admin_handler.go
new file mode 100644
index 0000000..193afe6
--- /dev/null
+++ b/internal/handlers/admin_handler.go
@@ -0,0 +1,406 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/phyowaiyan-dev/goappmon/internal/middleware"
+ "github.com/phyowaiyan-dev/goappmon/internal/services"
+ "github.com/phyowaiyan-dev/goappmon/web"
+)
+
+type AdminHandler struct {
+ renderer Renderer
+ adminService *services.AdminService
+}
+
+func NewAdminHandler(renderer Renderer, adminService *services.AdminService) *AdminHandler {
+ return &AdminHandler{renderer: renderer, adminService: adminService}
+}
+
+func (h *AdminHandler) Dashboard(c *gin.Context) {
+ dashboard, err := h.adminService.Dashboard(c.Request.Context())
+ if err != nil {
+ c.String(http.StatusInternalServerError, "failed to load dashboard")
+ return
+ }
+ admin, _ := middleware.CurrentAdmin(c)
+ _ = h.renderer.RenderPage(c.Writer, "dashboard.html", PageData{
+ Title: "Admin Dashboard",
+ Authenticated: true,
+ CurrentAdmin: admin,
+ Settings: dashboard.Settings,
+ Flags: dashboard.Flags,
+ AndroidReleases: dashboard.AndroidReleases,
+ IOSReleases: dashboard.IOSReleases,
+ MaintenanceHistory: dashboard.MaintenanceHistory,
+ BannerHistory: dashboard.BannerHistory,
+ AuditLogs: dashboard.AuditLogs,
+ SystemHealth: dashboard.SystemHealth,
+ Notice: friendlyNotice(c.Query("success")),
+ Error: friendlyError(c.Query("error")),
+ })
+}
+
+func (h *AdminHandler) SystemHealthPanel(c *gin.Context) {
+ pageData := PageData{
+ SystemHealth: h.adminService.SystemHealth(),
+ }
+ if isHXRequest(c) {
+ _ = h.renderer.RenderFragment(c.Writer, "system-health-panel", pageData)
+ return
+ }
+ _ = h.renderer.RenderFragment(c.Writer, "system-health-panel", pageData)
+}
+
+func (h *AdminHandler) AuditLogsPage(c *gin.Context) {
+ params, err := parseAuditLogParams(c)
+ if err != nil {
+ c.String(http.StatusBadRequest, err.Error())
+ return
+ }
+ result, err := h.adminService.SearchAuditLogs(c.Request.Context(), params)
+ if err != nil {
+ c.String(http.StatusInternalServerError, "failed to load audit logs")
+ return
+ }
+ admin, _ := middleware.CurrentAdmin(c)
+ prevURL, nextURL := buildAuditLogPageURLs(params, result.Page, result.TotalPages)
+ pageData := AuditLogPageData{
+ Title: "Audit Log",
+ Authenticated: true,
+ CurrentAdmin: admin,
+ Logs: result.Logs,
+ Query: params.Query,
+ Page: result.Page,
+ PageSize: result.PageSize,
+ Total: result.Total,
+ TotalPages: result.TotalPages,
+ HasPrev: result.Page > 1,
+ HasNext: result.Page < result.TotalPages,
+ PrevPage: result.Page - 1,
+ NextPage: result.Page + 1,
+ PrevURL: prevURL,
+ NextURL: nextURL,
+ Notice: friendlyNotice(c.Query("success")),
+ Error: friendlyError(c.Query("error")),
+ }
+ if isHXRequest(c) {
+ _ = h.renderer.RenderFragment(c.Writer, "audit-logs-table", pageData)
+ return
+ }
+ _ = h.renderer.RenderPage(c.Writer, "audit_logs.html", pageData)
+}
+
+func (h *AdminHandler) DownloadPostmanCollection(c *gin.Context) {
+ data, err := web.PostmanFS.ReadFile("postman/GoAppMon.postman_collection.json")
+ if err != nil {
+ c.String(http.StatusInternalServerError, "failed to load postman collection")
+ return
+ }
+ c.Header("Content-Disposition", `attachment; filename="GoAppMon.postman_collection.json"`)
+ c.Data(http.StatusOK, "application/json; charset=utf-8", data)
+}
+
+func (h *AdminHandler) UpdateApplication(c *gin.Context) {
+ appName := strings.TrimSpace(c.PostForm("app_name"))
+ apiURL := strings.TrimSpace(c.PostForm("api_url"))
+ if appName == "" {
+ c.Redirect(http.StatusFound, "/admin?error=app_name_required")
+ return
+ }
+ if err := h.adminService.UpdateApplication(c.Request.Context(), adminMeta(c), appName, apiURL); err != nil {
+ c.String(http.StatusInternalServerError, "failed to update application settings")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=application_updated")
+}
+
+func (h *AdminHandler) UpdatePlatforms(c *gin.Context) {
+ if err := h.adminService.UpdatePlatforms(c.Request.Context(), adminMeta(c), parseBoolForm(c.PostForm("android_enabled")), parseBoolForm(c.PostForm("ios_enabled"))); err != nil {
+ c.String(http.StatusInternalServerError, "failed to update platform settings")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=platforms_updated")
+}
+
+func (h *AdminHandler) UpdateVersion(c *gin.Context) {
+ androidLatest := strings.TrimSpace(c.PostForm("android_latest_version"))
+ androidMin := strings.TrimSpace(c.PostForm("android_min_version"))
+ androidForce := parseBoolForm(c.PostForm("android_force_update"))
+ androidNotes := strings.TrimSpace(c.PostForm("android_release_notes"))
+
+ iosLatest := strings.TrimSpace(c.PostForm("ios_latest_version"))
+ iosMin := strings.TrimSpace(c.PostForm("ios_min_version"))
+ iosForce := parseBoolForm(c.PostForm("ios_force_update"))
+ iosNotes := strings.TrimSpace(c.PostForm("ios_release_notes"))
+
+ if androidLatest != "" || androidMin != "" {
+ if err := h.adminService.PublishVersion(c.Request.Context(), adminMeta(c), "android", androidLatest, androidMin, androidForce, androidNotes); err != nil {
+ if code := versionErrorCode(err); code != "" {
+ c.Redirect(http.StatusFound, "/admin?error="+code)
+ return
+ }
+ c.String(http.StatusInternalServerError, "failed to update android version")
+ return
+ }
+ }
+ if iosLatest != "" || iosMin != "" {
+ if err := h.adminService.PublishVersion(c.Request.Context(), adminMeta(c), "ios", iosLatest, iosMin, iosForce, iosNotes); err != nil {
+ if code := versionErrorCode(err); code != "" {
+ c.Redirect(http.StatusFound, "/admin?error="+code)
+ return
+ }
+ c.String(http.StatusInternalServerError, "failed to update ios version")
+ return
+ }
+ }
+ c.Redirect(http.StatusFound, "/admin?success=version_updated")
+}
+
+func (h *AdminHandler) PublishVersion(c *gin.Context) {
+ platform := strings.ToLower(strings.TrimSpace(c.Param("platform")))
+ latest := strings.TrimSpace(c.PostForm("latest_version"))
+ minimum := strings.TrimSpace(c.PostForm("minimum_version"))
+ force := parseBoolForm(c.PostForm("force_update"))
+ notes := strings.TrimSpace(c.PostForm("release_notes"))
+ if platform != "android" && platform != "ios" {
+ c.String(http.StatusBadRequest, "invalid platform")
+ return
+ }
+ if latest == "" || minimum == "" {
+ c.Redirect(http.StatusFound, "/admin?error=version_required")
+ return
+ }
+ if err := h.adminService.PublishVersion(c.Request.Context(), adminMeta(c), platform, latest, minimum, force, notes); err != nil {
+ if errors.Is(err, services.ErrPlatformDisabled) {
+ c.String(http.StatusBadRequest, "platform is disabled")
+ return
+ }
+ if code := versionErrorCode(err); code != "" {
+ c.Redirect(http.StatusFound, "/admin?error="+code)
+ return
+ }
+ c.String(http.StatusInternalServerError, "failed to publish version")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=version_published")
+}
+
+func (h *AdminHandler) DeleteCurrentVersion(c *gin.Context) {
+ platform := strings.ToLower(strings.TrimSpace(c.Param("platform")))
+ if platform != "android" && platform != "ios" {
+ c.String(http.StatusBadRequest, "invalid platform")
+ return
+ }
+ if err := h.adminService.DeleteCurrentVersion(c.Request.Context(), adminMeta(c), platform); err != nil {
+ if code := versionErrorCode(err); code != "" {
+ c.Redirect(http.StatusFound, "/admin?error="+code)
+ return
+ }
+ c.String(http.StatusInternalServerError, "failed to delete version")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=version_deleted")
+}
+
+func (h *AdminHandler) UpdateMaintenance(c *gin.Context) {
+ enabled := parseBoolForm(c.PostForm("maintenance_mode"))
+ message := strings.TrimSpace(c.PostForm("maintenance_message"))
+ if err := h.adminService.UpdateMaintenance(c.Request.Context(), adminMeta(c), enabled, message); err != nil {
+ c.String(http.StatusInternalServerError, "failed to update maintenance")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=maintenance_updated")
+}
+
+func (h *AdminHandler) UpdateBanner(c *gin.Context) {
+ enabled := parseBoolForm(c.PostForm("banner_enabled"))
+ message := strings.TrimSpace(c.PostForm("banner_message"))
+ if err := h.adminService.UpdateBanner(c.Request.Context(), adminMeta(c), enabled, message); err != nil {
+ c.String(http.StatusInternalServerError, "failed to update banner")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=banner_updated")
+}
+
+func (h *AdminHandler) CreateFlag(c *gin.Context) {
+ key := strings.TrimSpace(c.PostForm("key"))
+ if key == "" {
+ c.Redirect(http.StatusFound, "/admin?error=flag_key_required")
+ return
+ }
+ if err := h.adminService.CreateFlag(c.Request.Context(), adminMeta(c), key, false); err != nil {
+ c.String(http.StatusInternalServerError, "failed to create feature flag")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=flag_created")
+}
+
+func (h *AdminHandler) UpdateFlag(c *gin.Context) {
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.String(http.StatusBadRequest, "invalid flag id")
+ return
+ }
+ key := strings.TrimSpace(c.PostForm("key"))
+ enabled := parseBoolForm(c.PostForm("enabled"))
+ if key == "" {
+ c.Redirect(http.StatusFound, "/admin?error=flag_key_required")
+ return
+ }
+ if err := h.adminService.UpdateFlag(c.Request.Context(), adminMeta(c), id, key, enabled); err != nil {
+ c.String(http.StatusInternalServerError, "failed to update feature flag")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=flag_updated")
+}
+
+func (h *AdminHandler) DeleteFlag(c *gin.Context) {
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ c.String(http.StatusBadRequest, "invalid flag id")
+ return
+ }
+ if err := h.adminService.DeleteFlag(c.Request.Context(), adminMeta(c), id); err != nil {
+ c.String(http.StatusInternalServerError, "failed to delete feature flag")
+ return
+ }
+ c.Redirect(http.StatusFound, "/admin?success=flag_deleted")
+}
+
+func adminMeta(c *gin.Context) services.ActionMeta {
+ admin, _ := middleware.CurrentAdmin(c)
+ meta := services.ActionMeta{
+ IP: c.ClientIP(),
+ UserAgent: c.Request.UserAgent(),
+ }
+ if admin != nil {
+ meta.ActorID = admin.ID
+ }
+ return meta
+}
+
+func parseBoolForm(value string) bool {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "1", "true", "on", "yes", "enabled":
+ return true
+ default:
+ return false
+ }
+}
+
+func friendlyNotice(code string) string {
+ switch code {
+ case "application_updated":
+ return "Application settings updated."
+ case "platforms_updated":
+ return "Platform settings updated."
+ case "version_updated":
+ return "Version history saved."
+ case "version_published":
+ return "Version release created."
+ case "version_deleted":
+ return "Version release deleted."
+ case "maintenance_updated":
+ return "Maintenance history saved."
+ case "banner_updated":
+ return "Banner history saved."
+ case "flag_created":
+ return "Feature flag created."
+ case "flag_updated":
+ return "Feature flag updated."
+ case "flag_deleted":
+ return "Feature flag deleted."
+ default:
+ return ""
+ }
+}
+
+func friendlyError(code string) string {
+ switch code {
+ case "app_name_required":
+ return "App name is required."
+ case "flag_key_required":
+ return "Feature flag key is required."
+ case "version_required":
+ return "Version details are required."
+ case "version_format_invalid":
+ return "Version must follow major.minor.patch format like 1.0.1."
+ case "version_not_increasing":
+ return "Latest version must be greater than the current version."
+ case "minimum_version_invalid":
+ return "Minimum version must be less than or equal to latest version."
+ case "version_delete_last":
+ return "At least one version must remain."
+ default:
+ return ""
+ }
+}
+
+func versionErrorCode(err error) string {
+ switch {
+ case errors.Is(err, services.ErrInvalidVersionFormat):
+ return "version_format_invalid"
+ case errors.Is(err, services.ErrLatestVersionNotGreater):
+ return "version_not_increasing"
+ case errors.Is(err, services.ErrMinimumVersionGreaterThanLatest):
+ return "minimum_version_invalid"
+ case errors.Is(err, services.ErrCannotDeleteLastVersion):
+ return "version_delete_last"
+ default:
+ return ""
+ }
+}
+
+func parseAuditLogParams(c *gin.Context) (services.AuditLogSearchParams, error) {
+ params := services.AuditLogSearchParams{
+ Query: strings.TrimSpace(c.Query("q")),
+ PageSize: 20,
+ }
+ if page, err := strconv.Atoi(strings.TrimSpace(c.Query("page"))); err == nil && page > 0 {
+ params.Page = page
+ } else {
+ params.Page = 1
+ }
+ if pageSize, err := strconv.Atoi(strings.TrimSpace(c.Query("page_size"))); err == nil && pageSize > 0 && pageSize <= 100 {
+ params.PageSize = pageSize
+ }
+ return params, nil
+}
+
+func buildAuditLogPageURLs(params services.AuditLogSearchParams, page, totalPages int) (string, string) {
+ base := "/admin/audit-logs"
+ build := func(targetPage int) string {
+ values := url.Values{}
+ if params.Query != "" {
+ values.Set("q", params.Query)
+ }
+ if params.PageSize > 0 {
+ values.Set("page_size", strconv.Itoa(params.PageSize))
+ }
+ values.Set("page", strconv.Itoa(targetPage))
+ encoded := values.Encode()
+ if encoded == "" {
+ return base
+ }
+ return base + "?" + encoded
+ }
+
+ var prevURL, nextURL string
+ if page > 1 {
+ prevURL = build(page - 1)
+ }
+ if page < totalPages {
+ nextURL = build(page + 1)
+ }
+ return prevURL, nextURL
+}
+
+func isHXRequest(c *gin.Context) bool {
+ return strings.EqualFold(strings.TrimSpace(c.GetHeader("HX-Request")), "true")
+}
diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go
new file mode 100644
index 0000000..9ba6c0a
--- /dev/null
+++ b/internal/handlers/auth_handler.go
@@ -0,0 +1,102 @@
+package handlers
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/phyowaiyan-dev/goappmon/internal/services"
+ "github.com/phyowaiyan-dev/goappmon/internal/utils"
+)
+
+type AuthHandler struct {
+ renderer Renderer
+ authService *services.AuthService
+ cookieName string
+ sessionTTL time.Duration
+}
+
+func NewAuthHandler(renderer Renderer, authService *services.AuthService, cookieName string, sessionTTL time.Duration) *AuthHandler {
+ return &AuthHandler{
+ renderer: renderer,
+ authService: authService,
+ cookieName: cookieName,
+ sessionTTL: sessionTTL,
+ }
+}
+
+func (h *AuthHandler) LoginPage(c *gin.Context) {
+ if h.isAuthenticated(c) {
+ c.Redirect(http.StatusFound, "/admin")
+ return
+ }
+ _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Authenticated: false})
+}
+
+func (h *AuthHandler) Login(c *gin.Context) {
+ if err := c.Request.ParseForm(); err != nil {
+ _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Error: "Invalid login form", Authenticated: false})
+ return
+ }
+
+ email := strings.TrimSpace(c.PostForm("email"))
+ password := c.PostForm("password")
+ if err := utils.ValidateEmail(email); err != nil {
+ _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Error: err.Error(), LoginEmail: email, Authenticated: false})
+ return
+ }
+ admin, err := h.authService.Authenticate(c.Request.Context(), email, password)
+ if err != nil {
+ _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Error: "Invalid email or password", LoginEmail: email, Authenticated: false})
+ return
+ }
+
+ token, err := h.authService.SignSession(admin.ID)
+ if err != nil {
+ _ = h.renderer.RenderPage(c.Writer, "login.html", PageData{Title: "Admin Login", Error: "Failed to create session", Authenticated: false})
+ return
+ }
+
+ h.setSessionCookie(c, token)
+ c.Redirect(http.StatusFound, "/admin")
+}
+
+func (h *AuthHandler) Logout(c *gin.Context) {
+ h.clearSessionCookie(c)
+ c.Redirect(http.StatusFound, "/admin/login")
+}
+
+func (h *AuthHandler) isAuthenticated(c *gin.Context) bool {
+ cookie, err := c.Cookie(h.cookieName)
+ if err != nil || strings.TrimSpace(cookie) == "" {
+ return false
+ }
+ _, err = h.authService.VerifySession(cookie)
+ return err == nil
+}
+
+func (h *AuthHandler) setSessionCookie(c *gin.Context, token string) {
+ secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: h.cookieName,
+ Value: token,
+ Path: "/",
+ MaxAge: int(h.sessionTTL.Seconds()),
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func (h *AuthHandler) clearSessionCookie(c *gin.Context) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: h.cookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: false,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
diff --git a/internal/handlers/public_handler.go b/internal/handlers/public_handler.go
new file mode 100644
index 0000000..756f970
--- /dev/null
+++ b/internal/handlers/public_handler.go
@@ -0,0 +1,57 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/phyowaiyan-dev/goappmon/internal/services"
+ "github.com/phyowaiyan-dev/goappmon/internal/utils"
+)
+
+type PublicHandler struct {
+ statusService *services.StatusService
+}
+
+func NewPublicHandler(statusService *services.StatusService) *PublicHandler {
+ return &PublicHandler{statusService: statusService}
+}
+
+func (h *PublicHandler) Health(c *gin.Context) {
+ utils.JSON(c, http.StatusOK, gin.H{"status": "ok"})
+}
+
+func (h *PublicHandler) Status(c *gin.Context) {
+ status, err := h.statusService.PublicStatus(c.Request.Context())
+ if err != nil {
+ utils.JSONError(c, http.StatusInternalServerError, "failed to load status")
+ return
+ }
+ utils.JSON(c, http.StatusOK, status)
+}
+
+func (h *PublicHandler) Version(c *gin.Context) {
+ version, err := h.statusService.PublicVersion(c.Request.Context())
+ if err != nil {
+ utils.JSONError(c, http.StatusInternalServerError, "failed to load version")
+ return
+ }
+ utils.JSON(c, http.StatusOK, version)
+}
+
+func (h *PublicHandler) Config(c *gin.Context) {
+ cfg, err := h.statusService.PublicConfig(c.Request.Context())
+ if err != nil {
+ utils.JSONError(c, http.StatusInternalServerError, "failed to load config")
+ return
+ }
+ utils.JSON(c, http.StatusOK, cfg)
+}
+
+func (h *PublicHandler) FeatureFlags(c *gin.Context) {
+ flags, err := h.statusService.PublicFeatureFlags(c.Request.Context())
+ if err != nil {
+ utils.JSONError(c, http.StatusInternalServerError, "failed to load feature flags")
+ return
+ }
+ utils.JSON(c, http.StatusOK, flags)
+}
diff --git a/internal/handlers/render.go b/internal/handlers/render.go
new file mode 100644
index 0000000..c9f4ea4
--- /dev/null
+++ b/internal/handlers/render.go
@@ -0,0 +1,59 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+type Renderer interface {
+ RenderPage(w http.ResponseWriter, page string, data any) error
+ RenderFragment(w http.ResponseWriter, fragment string, data any) error
+}
+
+type PageData struct {
+ Title string
+ Error string
+ Notice string
+ Authenticated bool
+ CurrentAdmin *models.Admin
+ Settings *models.Setting
+ Flags []models.FeatureFlag
+ AndroidReleases []models.VersionRelease
+ IOSReleases []models.VersionRelease
+ MaintenanceHistory []models.StateChange
+ BannerHistory []models.StateChange
+ AuditLogs []models.AuditLog
+ SystemHealth any
+ AdminName string
+ AdminEmail string
+ AppName string
+ LoginEmail string
+ SetupComplete bool
+}
+
+type AuditLogPageData struct {
+ Title string
+ Error string
+ Notice string
+ Authenticated bool
+ CurrentAdmin *models.Admin
+ Logs []models.AuditLog
+ Query string
+ Action string
+ EntityType string
+ EntityID string
+ ActorID string
+ From string
+ To string
+ Page int
+ PageSize int
+ Total int64
+ TotalPages int
+ HasPrev bool
+ HasNext bool
+ PrevPage int
+ NextPage int
+ PrevURL string
+ NextURL string
+}
diff --git a/internal/handlers/setup_handler.go b/internal/handlers/setup_handler.go
new file mode 100644
index 0000000..1b46c94
--- /dev/null
+++ b/internal/handlers/setup_handler.go
@@ -0,0 +1,89 @@
+package handlers
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/phyowaiyan-dev/goappmon/internal/services"
+ "github.com/phyowaiyan-dev/goappmon/internal/utils"
+)
+
+type SetupHandler struct {
+ renderer Renderer
+ setupService *services.SetupService
+}
+
+func NewSetupHandler(renderer Renderer, setupService *services.SetupService) *SetupHandler {
+ return &SetupHandler{renderer: renderer, setupService: setupService}
+}
+
+func (h *SetupHandler) Page(c *gin.Context) {
+ complete, err := h.setupService.IsSetupComplete(c.Request.Context())
+ if err == nil && complete {
+ c.Redirect(http.StatusFound, "/admin/login")
+ return
+ }
+ _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{Title: "Initial Setup", Authenticated: false})
+}
+
+func (h *SetupHandler) Submit(c *gin.Context) {
+ if err := c.Request.ParseForm(); err != nil {
+ _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{Title: "Initial Setup", Error: "Invalid setup form", Authenticated: false})
+ return
+ }
+
+ adminName := strings.TrimSpace(c.PostForm("admin_name"))
+ adminEmail := strings.TrimSpace(c.PostForm("admin_email"))
+ password := c.PostForm("password")
+ appName := strings.TrimSpace(c.PostForm("app_name"))
+
+ if adminName == "" || adminEmail == "" || password == "" || appName == "" {
+ _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{
+ Title: "Initial Setup",
+ Error: "All fields are required",
+ AdminName: adminName,
+ AdminEmail: adminEmail,
+ AppName: appName,
+ Authenticated: false,
+ })
+ return
+ }
+
+ if err := utils.ValidateAdminName(adminName); err != nil {
+ _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{
+ Title: "Initial Setup",
+ Error: err.Error(),
+ AdminName: adminName,
+ AdminEmail: adminEmail,
+ AppName: appName,
+ Authenticated: false,
+ })
+ return
+ }
+ if err := utils.ValidateEmail(adminEmail); err != nil {
+ _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{
+ Title: "Initial Setup",
+ Error: err.Error(),
+ AdminName: adminName,
+ AdminEmail: adminEmail,
+ AppName: appName,
+ Authenticated: false,
+ })
+ return
+ }
+
+ if err := h.setupService.CreateInitialSetup(c.Request.Context(), adminName, adminEmail, password, appName); err != nil {
+ _ = h.renderer.RenderPage(c.Writer, "setup.html", PageData{
+ Title: "Initial Setup",
+ Error: "Setup could not be completed",
+ AdminName: adminName,
+ AdminEmail: adminEmail,
+ AppName: appName,
+ Authenticated: false,
+ })
+ return
+ }
+
+ c.Redirect(http.StatusFound, "/admin/login")
+}
diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go
new file mode 100644
index 0000000..9e9bd01
--- /dev/null
+++ b/internal/middleware/auth.go
@@ -0,0 +1,84 @@
+package middleware
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+ "github.com/phyowaiyan-dev/goappmon/internal/services"
+)
+
+const CurrentAdminKey = "current_admin"
+
+func SetupRedirect(setupService *services.SetupService) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if bypassSetupRedirect(c.Request.URL.Path) {
+ c.Next()
+ return
+ }
+
+ complete, err := setupService.IsSetupComplete(c.Request.Context())
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check setup status"})
+ return
+ }
+ if !complete {
+ c.Redirect(http.StatusFound, "/setup")
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
+
+func RequireAuth(authService *services.AuthService, adminRepo *repositories.AdminRepository, cookieName string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ cookie, err := c.Cookie(cookieName)
+ if err != nil || strings.TrimSpace(cookie) == "" {
+ c.Redirect(http.StatusFound, "/admin/login")
+ c.Abort()
+ return
+ }
+
+ adminID, err := authService.VerifySession(cookie)
+ if err != nil {
+ c.Redirect(http.StatusFound, "/admin/login")
+ c.Abort()
+ return
+ }
+
+ admin, err := adminRepo.GetByID(c.Request.Context(), adminID)
+ if err != nil {
+ c.Redirect(http.StatusFound, "/admin/login")
+ c.Abort()
+ return
+ }
+
+ c.Set(CurrentAdminKey, admin)
+ c.Next()
+ }
+}
+
+func CurrentAdmin(c *gin.Context) (*models.Admin, bool) {
+ value, ok := c.Get(CurrentAdminKey)
+ if !ok {
+ return nil, false
+ }
+ admin, ok := value.(*models.Admin)
+ return admin, ok
+}
+
+func bypassSetupRedirect(path string) bool {
+ switch {
+ case path == "/setup":
+ return true
+ case path == "/favicon.ico":
+ return true
+ case strings.HasPrefix(path, "/assets/"):
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go
new file mode 100644
index 0000000..0cc6819
--- /dev/null
+++ b/internal/middleware/auth_test.go
@@ -0,0 +1,123 @@
+package middleware
+
+import (
+ "context"
+ "database/sql"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/phyowaiyan-dev/goappmon/internal/database"
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+ "github.com/phyowaiyan-dev/goappmon/internal/services"
+ "github.com/phyowaiyan-dev/goappmon/internal/utils"
+)
+
+func TestSetupRedirect(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db := newMiddlewareDB(t)
+ setupService := services.NewSetupService(db)
+
+ r := gin.New()
+ r.Use(SetupRedirect(setupService))
+ r.GET("/health", func(c *gin.Context) {
+ c.String(http.StatusOK, "ok")
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/setup" {
+ t.Fatalf("expected redirect to /setup, got %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+
+ if err := setupService.CreateInitialSetup(context.Background(), "Admin", "admin@example.com", "secret123", "GoAppMon"); err != nil {
+ t.Fatalf("create setup: %v", err)
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/health", nil)
+ rec = httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
+ t.Fatalf("expected ok response, got %d %q", rec.Code, rec.Body.String())
+ }
+}
+
+func TestRequireAuthAndCurrentAdmin(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db := newMiddlewareDB(t)
+ adminRepo := repositories.NewAdminRepository(db)
+ hashed, err := utils.HashPassword("secret123")
+ if err != nil {
+ t.Fatalf("hash password: %v", err)
+ }
+ adminID, err := adminRepo.Create(context.Background(), models.Admin{
+ Name: "Admin",
+ Email: "admin@example.com",
+ PasswordHash: hashed,
+ CreatedAt: time.Now().UTC(),
+ })
+ if err != nil {
+ t.Fatalf("create admin: %v", err)
+ }
+
+ authService := services.NewAuthService(adminRepo, []byte("01234567890123456789012345678901"), time.Hour)
+ token, err := authService.SignSession(adminID)
+ if err != nil {
+ t.Fatalf("sign session: %v", err)
+ }
+
+ r := gin.New()
+ r.Use(RequireAuth(authService, adminRepo, "goappmon_session"))
+ r.GET("/admin", func(c *gin.Context) {
+ admin, ok := CurrentAdmin(c)
+ if !ok {
+ c.String(http.StatusInternalServerError, "missing admin")
+ return
+ }
+ c.String(http.StatusOK, admin.Email)
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/admin", nil)
+ rec := httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/login" {
+ t.Fatalf("expected redirect for missing cookie, got %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/admin", nil)
+ req.AddCookie(&http.Cookie{Name: "goappmon_session", Value: token + "tamper"})
+ rec = httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/login" {
+ t.Fatalf("expected redirect for bad cookie, got %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/admin", nil)
+ req.AddCookie(&http.Cookie{Name: "goappmon_session", Value: token})
+ rec = httptest.NewRecorder()
+ r.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || rec.Body.String() != "admin@example.com" {
+ t.Fatalf("expected authenticated response, got %d %q", rec.Code, rec.Body.String())
+ }
+}
+
+func newMiddlewareDB(t *testing.T) *sql.DB {
+ t.Helper()
+ dbPath := filepath.Join(t.TempDir(), "test.sqlite")
+ db, err := database.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := database.Migrate(context.Background(), db); err != nil {
+ t.Fatalf("migrate db: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = db.Close()
+ })
+ return db
+}
diff --git a/internal/models/admin.go b/internal/models/admin.go
new file mode 100644
index 0000000..7c2a8f7
--- /dev/null
+++ b/internal/models/admin.go
@@ -0,0 +1,11 @@
+package models
+
+import "time"
+
+type Admin struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ PasswordHash string `json:"-"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/internal/models/audit_log.go b/internal/models/audit_log.go
new file mode 100644
index 0000000..5f9b454
--- /dev/null
+++ b/internal/models/audit_log.go
@@ -0,0 +1,17 @@
+package models
+
+import "time"
+
+type AuditLog struct {
+ ID int64 `json:"id"`
+ ActorAdminID int64 `json:"actor_admin_id"`
+ ActorName string `json:"actor_name"`
+ Action string `json:"action"`
+ EntityType string `json:"entity_type"`
+ EntityID string `json:"entity_id"`
+ BeforeJSON string `json:"before_json"`
+ AfterJSON string `json:"after_json"`
+ IP string `json:"ip"`
+ UserAgent string `json:"user_agent"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/internal/models/feature_flag.go b/internal/models/feature_flag.go
new file mode 100644
index 0000000..3a09615
--- /dev/null
+++ b/internal/models/feature_flag.go
@@ -0,0 +1,7 @@
+package models
+
+type FeatureFlag struct {
+ ID int64 `json:"id"`
+ Key string `json:"key"`
+ Enabled bool `json:"enabled"`
+}
diff --git a/internal/models/setting.go b/internal/models/setting.go
new file mode 100644
index 0000000..fafb05e
--- /dev/null
+++ b/internal/models/setting.go
@@ -0,0 +1,23 @@
+package models
+
+import "time"
+
+type Setting struct {
+ ID int64 `json:"id"`
+ AppName string `json:"app_name"`
+ AndroidEnabled bool `json:"android_enabled"`
+ AndroidLatestVersion string `json:"android_latest_version"`
+ AndroidMinVersion string `json:"android_min_version"`
+ AndroidForceUpdate bool `json:"android_force_update"`
+ IOSEnabled bool `json:"ios_enabled"`
+ IOSLatestVersion string `json:"ios_latest_version"`
+ IOSMinVersion string `json:"ios_min_version"`
+ IOSForceUpdate bool `json:"ios_force_update"`
+ MaintenanceMode bool `json:"maintenance_mode"`
+ MaintenanceMessage string `json:"maintenance_message"`
+ BannerEnabled bool `json:"banner_enabled"`
+ BannerMessage string `json:"banner_message"`
+ APIURL string `json:"api_url"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/internal/models/state_change.go b/internal/models/state_change.go
new file mode 100644
index 0000000..f93a829
--- /dev/null
+++ b/internal/models/state_change.go
@@ -0,0 +1,13 @@
+package models
+
+import "time"
+
+type StateChange struct {
+ ID int64 `json:"id"`
+ Kind string `json:"kind"`
+ Enabled bool `json:"enabled"`
+ Message string `json:"message"`
+ CreatedByAdminID int64 `json:"created_by_admin_id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/internal/models/version_release.go b/internal/models/version_release.go
new file mode 100644
index 0000000..9567a0c
--- /dev/null
+++ b/internal/models/version_release.go
@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+type VersionRelease struct {
+ ID int64 `json:"id"`
+ Platform string `json:"platform"`
+ LatestVersion string `json:"latest_version"`
+ MinimumVersion string `json:"minimum_version"`
+ ForceUpdate bool `json:"force_update"`
+ ReleaseNotes string `json:"release_notes"`
+ CreatedByAdminID int64 `json:"created_by_admin_id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/internal/repositories/admin_repository.go b/internal/repositories/admin_repository.go
new file mode 100644
index 0000000..7f0538e
--- /dev/null
+++ b/internal/repositories/admin_repository.go
@@ -0,0 +1,70 @@
+package repositories
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+var ErrAdminNotFound = errors.New("admin not found")
+
+type AdminRepository struct {
+ db DBTX
+}
+
+func NewAdminRepository(db DBTX) *AdminRepository {
+ return &AdminRepository{db: db}
+}
+
+func (r *AdminRepository) Count(ctx context.Context) (int64, error) {
+ var count int64
+ if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM admins`).Scan(&count); err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+func (r *AdminRepository) Create(ctx context.Context, admin models.Admin) (int64, error) {
+ result, err := r.db.ExecContext(ctx, `
+ INSERT INTO admins (name, email, password_hash, created_at)
+ VALUES (?, ?, ?, ?)
+ `, admin.Name, admin.Email, admin.PasswordHash, admin.CreatedAt.Unix())
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+func (r *AdminRepository) GetByEmail(ctx context.Context, email string) (*models.Admin, error) {
+ row := r.db.QueryRowContext(ctx, `
+ SELECT id, name, email, password_hash, created_at
+ FROM admins
+ WHERE email = ?
+ `, email)
+ return scanAdmin(row)
+}
+
+func (r *AdminRepository) GetByID(ctx context.Context, id int64) (*models.Admin, error) {
+ row := r.db.QueryRowContext(ctx, `
+ SELECT id, name, email, password_hash, created_at
+ FROM admins
+ WHERE id = ?
+ `, id)
+ return scanAdmin(row)
+}
+
+func scanAdmin(row *sql.Row) (*models.Admin, error) {
+ var admin models.Admin
+ var createdAt int64
+ if err := row.Scan(&admin.ID, &admin.Name, &admin.Email, &admin.PasswordHash, &createdAt); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrAdminNotFound
+ }
+ return nil, err
+ }
+ admin.CreatedAt = time.Unix(createdAt, 0).UTC()
+ return &admin, nil
+}
diff --git a/internal/repositories/admin_repository_test.go b/internal/repositories/admin_repository_test.go
new file mode 100644
index 0000000..145eed9
--- /dev/null
+++ b/internal/repositories/admin_repository_test.go
@@ -0,0 +1,88 @@
+package repositories
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/database"
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+func newTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+
+ dbPath := filepath.Join(t.TempDir(), "test.sqlite")
+ db, err := database.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := database.Migrate(context.Background(), db); err != nil {
+ t.Fatalf("migrate db: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = db.Close()
+ })
+ return db
+}
+
+func TestAdminRepositoryCRUD(t *testing.T) {
+ db := newTestDB(t)
+ repo := NewAdminRepository(db)
+ ctx := context.Background()
+
+ count, err := repo.Count(ctx)
+ if err != nil {
+ t.Fatalf("count empty: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("expected empty count, got %d", count)
+ }
+
+ createdAt := time.Unix(1_700_000_000, 0).UTC()
+ id, err := repo.Create(ctx, models.Admin{
+ Name: "Admin",
+ Email: "admin@example.com",
+ PasswordHash: "hash",
+ CreatedAt: createdAt,
+ })
+ if err != nil {
+ t.Fatalf("create admin: %v", err)
+ }
+ if id == 0 {
+ t.Fatal("expected non-zero id")
+ }
+
+ count, err = repo.Count(ctx)
+ if err != nil {
+ t.Fatalf("count after create: %v", err)
+ }
+ if count != 1 {
+ t.Fatalf("expected count 1, got %d", count)
+ }
+
+ admin, err := repo.GetByEmail(ctx, "admin@example.com")
+ if err != nil {
+ t.Fatalf("get by email: %v", err)
+ }
+ if admin.Name != "Admin" || admin.Email != "admin@example.com" || admin.PasswordHash != "hash" {
+ t.Fatalf("unexpected admin: %+v", admin)
+ }
+ if !admin.CreatedAt.Equal(createdAt) {
+ t.Fatalf("unexpected created at: %v", admin.CreatedAt)
+ }
+
+ byID, err := repo.GetByID(ctx, id)
+ if err != nil {
+ t.Fatalf("get by id: %v", err)
+ }
+ if byID.ID != id {
+ t.Fatalf("unexpected id: %d", byID.ID)
+ }
+
+ if _, err := repo.GetByEmail(ctx, "missing@example.com"); err != ErrAdminNotFound {
+ t.Fatalf("expected ErrAdminNotFound, got %v", err)
+ }
+}
diff --git a/internal/repositories/audit_log_repository.go b/internal/repositories/audit_log_repository.go
new file mode 100644
index 0000000..361f9b0
--- /dev/null
+++ b/internal/repositories/audit_log_repository.go
@@ -0,0 +1,148 @@
+package repositories
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+type AuditLogRepository struct {
+ db DBTX
+}
+
+type AuditLogFilter struct {
+ Query string
+ Limit int
+ Offset int
+}
+
+type AuditLogPage struct {
+ Logs []models.AuditLog
+ Total int64
+}
+
+func NewAuditLogRepository(db DBTX) *AuditLogRepository {
+ return &AuditLogRepository{db: db}
+}
+
+func (r *AuditLogRepository) Create(ctx context.Context, log models.AuditLog) (int64, error) {
+ result, err := r.db.ExecContext(ctx, `
+ INSERT INTO audit_logs (
+ actor_admin_id, action, entity_type, entity_id, before_json, after_json, ip, user_agent, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ log.ActorAdminID,
+ log.Action,
+ log.EntityType,
+ log.EntityID,
+ log.BeforeJSON,
+ log.AfterJSON,
+ log.IP,
+ log.UserAgent,
+ log.CreatedAt.Unix(),
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+func (r *AuditLogRepository) ListRecent(ctx context.Context, limit int) ([]models.AuditLog, error) {
+ page, err := r.Search(ctx, AuditLogFilter{Limit: limit})
+ if err != nil {
+ return nil, err
+ }
+ return page.Logs, nil
+}
+
+func (r *AuditLogRepository) Search(ctx context.Context, filter AuditLogFilter) (AuditLogPage, error) {
+ if filter.Limit <= 0 {
+ filter.Limit = 10
+ }
+ if filter.Offset < 0 {
+ filter.Offset = 0
+ }
+
+ where, args := buildAuditWhere(filter)
+ query := fmt.Sprintf(`
+ SELECT al.id, al.actor_admin_id, COALESCE(a.name, ''), al.action, al.entity_type, al.entity_id,
+ al.before_json, al.after_json, al.ip, al.user_agent, al.created_at
+ FROM audit_logs al
+ LEFT JOIN admins a ON a.id = al.actor_admin_id
+ %s
+ ORDER BY al.created_at DESC, al.id DESC
+ LIMIT ? OFFSET ?
+ `, where)
+ argsWithLimit := append(args, filter.Limit, filter.Offset)
+ rows, err := r.db.QueryContext(ctx, query, argsWithLimit...)
+ if err != nil {
+ return AuditLogPage{}, err
+ }
+ defer rows.Close()
+
+ logs := make([]models.AuditLog, 0)
+ for rows.Next() {
+ entry, err := scanAuditLog(rows)
+ if err != nil {
+ return AuditLogPage{}, err
+ }
+ logs = append(logs, *entry)
+ }
+ if err := rows.Err(); err != nil {
+ return AuditLogPage{}, err
+ }
+
+ countQuery := fmt.Sprintf(`
+ SELECT COUNT(*)
+ FROM audit_logs al
+ LEFT JOIN admins a ON a.id = al.actor_admin_id
+ %s
+ `, where)
+ var total int64
+ if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
+ return AuditLogPage{}, err
+ }
+
+ return AuditLogPage{Logs: logs, Total: total}, nil
+}
+
+func buildAuditWhere(filter AuditLogFilter) (string, []any) {
+ clauses := make([]string, 0, 2)
+ args := make([]any, 0, 2)
+ clauses = append(clauses, "1=1")
+
+ if q := strings.TrimSpace(filter.Query); q != "" {
+ like := "%" + strings.ToLower(q) + "%"
+ clauses = append(clauses, "(LOWER(al.action) LIKE ? OR LOWER(al.entity_type) LIKE ? OR LOWER(al.entity_id) LIKE ? OR LOWER(al.ip) LIKE ? OR LOWER(al.user_agent) LIKE ? OR LOWER(COALESCE(a.name, '')) LIKE ?)")
+ for i := 0; i < 6; i++ {
+ args = append(args, like)
+ }
+ }
+ return "WHERE " + strings.Join(clauses, " AND "), args
+}
+
+func scanAuditLog(rows *sql.Rows) (*models.AuditLog, error) {
+ var entry models.AuditLog
+ var createdAt int64
+ if err := rows.Scan(
+ &entry.ID,
+ &entry.ActorAdminID,
+ &entry.ActorName,
+ &entry.Action,
+ &entry.EntityType,
+ &entry.EntityID,
+ &entry.BeforeJSON,
+ &entry.AfterJSON,
+ &entry.IP,
+ &entry.UserAgent,
+ &createdAt,
+ ); err != nil {
+ return nil, err
+ }
+ entry.CreatedAt = time.Unix(createdAt, 0).UTC()
+ return &entry, nil
+}
diff --git a/internal/repositories/audit_log_repository_test.go b/internal/repositories/audit_log_repository_test.go
new file mode 100644
index 0000000..817b1f8
--- /dev/null
+++ b/internal/repositories/audit_log_repository_test.go
@@ -0,0 +1,61 @@
+package repositories
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+func TestAuditLogRepositorySearch(t *testing.T) {
+ db := newTestDB(t)
+ ctx := context.Background()
+ adminRepo := NewAdminRepository(db)
+ auditRepo := NewAuditLogRepository(db)
+
+ adminID, err := adminRepo.Create(ctx, models.Admin{
+ Name: "Admin",
+ Email: "admin@example.com",
+ PasswordHash: "hash",
+ CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
+ })
+ if err != nil {
+ t.Fatalf("create admin: %v", err)
+ }
+
+ now := time.Unix(1_700_000_100, 0).UTC()
+ if _, err := auditRepo.Create(ctx, models.AuditLog{
+ ActorAdminID: adminID,
+ Action: "version.created",
+ EntityType: "version_release",
+ EntityID: "android",
+ IP: "127.0.0.1",
+ UserAgent: "test",
+ CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("create audit: %v", err)
+ }
+ if _, err := auditRepo.Create(ctx, models.AuditLog{
+ ActorAdminID: adminID,
+ Action: "flag.deleted",
+ EntityType: "feature_flag",
+ EntityID: "1",
+ IP: "127.0.0.1",
+ UserAgent: "test",
+ CreatedAt: now.Add(time.Minute),
+ }); err != nil {
+ t.Fatalf("create audit: %v", err)
+ }
+
+ page, err := auditRepo.Search(ctx, AuditLogFilter{Query: "deleted", Limit: 10})
+ if err != nil {
+ t.Fatalf("search audit: %v", err)
+ }
+ if page.Total != 1 || len(page.Logs) != 1 {
+ t.Fatalf("unexpected page result: %+v", page)
+ }
+ if page.Logs[0].ActorName != "Admin" {
+ t.Fatalf("expected actor name, got %+v", page.Logs[0])
+ }
+}
diff --git a/internal/repositories/dbtx.go b/internal/repositories/dbtx.go
new file mode 100644
index 0000000..3910d34
--- /dev/null
+++ b/internal/repositories/dbtx.go
@@ -0,0 +1,12 @@
+package repositories
+
+import (
+ "context"
+ "database/sql"
+)
+
+type DBTX interface {
+ ExecContext(context.Context, string, ...any) (sql.Result, error)
+ QueryContext(context.Context, string, ...any) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+}
diff --git a/internal/repositories/feature_flag_repository.go b/internal/repositories/feature_flag_repository.go
new file mode 100644
index 0000000..9bd0895
--- /dev/null
+++ b/internal/repositories/feature_flag_repository.go
@@ -0,0 +1,123 @@
+package repositories
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+var ErrFeatureFlagNotFound = errors.New("feature flag not found")
+
+type FeatureFlagRepository struct {
+ db DBTX
+}
+
+func NewFeatureFlagRepository(db DBTX) *FeatureFlagRepository {
+ return &FeatureFlagRepository{db: db}
+}
+
+func (r *FeatureFlagRepository) List(ctx context.Context) ([]models.FeatureFlag, error) {
+ rows, err := r.db.QueryContext(ctx, `SELECT id, key, enabled FROM feature_flags ORDER BY key ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ flags := make([]models.FeatureFlag, 0)
+ for rows.Next() {
+ var flag models.FeatureFlag
+ var enabled int
+ if err := rows.Scan(&flag.ID, &flag.Key, &enabled); err != nil {
+ return nil, err
+ }
+ flag.Enabled = enabled != 0
+ flags = append(flags, flag)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return flags, nil
+}
+
+func (r *FeatureFlagRepository) GetByID(ctx context.Context, id int64) (*models.FeatureFlag, error) {
+ row := r.db.QueryRowContext(ctx, `SELECT id, key, enabled FROM feature_flags WHERE id = ?`, id)
+ var flag models.FeatureFlag
+ var enabled int
+ if err := row.Scan(&flag.ID, &flag.Key, &enabled); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrFeatureFlagNotFound
+ }
+ return nil, err
+ }
+ flag.Enabled = enabled != 0
+ return &flag, nil
+}
+
+func (r *FeatureFlagRepository) Create(ctx context.Context, key string, enabled bool) (int64, error) {
+ result, err := r.db.ExecContext(ctx, `
+ INSERT INTO feature_flags (key, enabled)
+ VALUES (?, ?)
+ `, key, boolToInt(enabled))
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+func (r *FeatureFlagRepository) Update(ctx context.Context, id int64, key string, enabled bool) error {
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE feature_flags
+ SET key = ?, enabled = ?
+ WHERE id = ?
+ `, key, boolToInt(enabled), id)
+ if err != nil {
+ return err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return ErrFeatureFlagNotFound
+ }
+ return nil
+}
+
+func (r *FeatureFlagRepository) Delete(ctx context.Context, id int64) error {
+ result, err := r.db.ExecContext(ctx, `DELETE FROM feature_flags WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return ErrFeatureFlagNotFound
+ }
+ return nil
+}
+
+func (r *FeatureFlagRepository) AsMap(ctx context.Context) (map[string]bool, error) {
+ rows, err := r.db.QueryContext(ctx, `SELECT key, enabled FROM feature_flags ORDER BY key ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ flags := make(map[string]bool)
+ for rows.Next() {
+ var key string
+ var enabled int
+ if err := rows.Scan(&key, &enabled); err != nil {
+ return nil, err
+ }
+ flags[key] = enabled != 0
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return flags, nil
+}
diff --git a/internal/repositories/feature_flag_repository_test.go b/internal/repositories/feature_flag_repository_test.go
new file mode 100644
index 0000000..690908a
--- /dev/null
+++ b/internal/repositories/feature_flag_repository_test.go
@@ -0,0 +1,77 @@
+package repositories
+
+import (
+ "context"
+ "testing"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+func TestFeatureFlagRepositoryLifecycle(t *testing.T) {
+ db := newTestDB(t)
+ repo := NewFeatureFlagRepository(db)
+ ctx := context.Background()
+
+ id, err := repo.Create(ctx, "chat", true)
+ if err != nil {
+ t.Fatalf("create flag: %v", err)
+ }
+ if id == 0 {
+ t.Fatal("expected non-zero id")
+ }
+ if _, err := repo.Create(ctx, "payment", false); err != nil {
+ t.Fatalf("create second flag: %v", err)
+ }
+
+ flags, err := repo.List(ctx)
+ if err != nil {
+ t.Fatalf("list flags: %v", err)
+ }
+ if len(flags) != 2 {
+ t.Fatalf("expected 2 flags, got %d", len(flags))
+ }
+
+ flagMap, err := repo.AsMap(ctx)
+ if err != nil {
+ t.Fatalf("flags as map: %v", err)
+ }
+ if !flagMap["chat"] || flagMap["payment"] {
+ t.Fatalf("unexpected flag map: %#v", flagMap)
+ }
+
+ if err := repo.Update(ctx, id, "chat-v2", false); err != nil {
+ t.Fatalf("update flag: %v", err)
+ }
+ updated, err := repo.List(ctx)
+ if err != nil {
+ t.Fatalf("list after update: %v", err)
+ }
+ if updated[0].Key != "chat-v2" || updated[0].Enabled {
+ t.Fatalf("unexpected updated flag: %+v", updated[0])
+ }
+
+ if err := repo.Delete(ctx, id); err != nil {
+ t.Fatalf("delete flag: %v", err)
+ }
+ remaining, err := repo.List(ctx)
+ if err != nil {
+ t.Fatalf("list after delete: %v", err)
+ }
+ if len(remaining) != 1 {
+ t.Fatalf("expected 1 flag, got %d", len(remaining))
+ }
+}
+
+func TestFeatureFlagRepositoryMissing(t *testing.T) {
+ db := newTestDB(t)
+ repo := NewFeatureFlagRepository(db)
+ ctx := context.Background()
+
+ if err := repo.Update(ctx, 999, "missing", true); err != ErrFeatureFlagNotFound {
+ t.Fatalf("expected ErrFeatureFlagNotFound on update, got %v", err)
+ }
+ if err := repo.Delete(ctx, 999); err != ErrFeatureFlagNotFound {
+ t.Fatalf("expected ErrFeatureFlagNotFound on delete, got %v", err)
+ }
+ _ = models.FeatureFlag{}
+}
diff --git a/internal/repositories/setting_repository.go b/internal/repositories/setting_repository.go
new file mode 100644
index 0000000..c444892
--- /dev/null
+++ b/internal/repositories/setting_repository.go
@@ -0,0 +1,199 @@
+package repositories
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+var ErrSettingsNotFound = errors.New("settings not found")
+
+type SettingRepository struct {
+ db DBTX
+}
+
+func NewSettingRepository(db DBTX) *SettingRepository {
+ return &SettingRepository{db: db}
+}
+
+func (r *SettingRepository) Count(ctx context.Context) (int64, error) {
+ var count int64
+ if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM settings`).Scan(&count); err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+func (r *SettingRepository) Create(ctx context.Context, setting models.Setting) (int64, error) {
+ result, err := r.db.ExecContext(ctx, `
+ INSERT INTO settings (
+ app_name, android_enabled, android_latest_version, android_min_version, android_force_update,
+ ios_enabled, ios_latest_version, ios_min_version, ios_force_update,
+ maintenance_mode, maintenance_message, banner_enabled, banner_message, api_url,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ setting.AppName,
+ boolToInt(setting.AndroidEnabled),
+ setting.AndroidLatestVersion,
+ setting.AndroidMinVersion,
+ boolToInt(setting.AndroidForceUpdate),
+ boolToInt(setting.IOSEnabled),
+ setting.IOSLatestVersion,
+ setting.IOSMinVersion,
+ boolToInt(setting.IOSForceUpdate),
+ boolToInt(setting.MaintenanceMode),
+ setting.MaintenanceMessage,
+ boolToInt(setting.BannerEnabled),
+ setting.BannerMessage,
+ setting.APIURL,
+ setting.CreatedAt.Unix(),
+ setting.UpdatedAt.Unix(),
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+func (r *SettingRepository) GetCurrent(ctx context.Context) (*models.Setting, error) {
+ row := r.db.QueryRowContext(ctx, `
+ SELECT id, app_name, android_enabled, android_latest_version, android_min_version, android_force_update,
+ ios_enabled, ios_latest_version, ios_min_version, ios_force_update, maintenance_mode,
+ maintenance_message, banner_enabled, banner_message, api_url, created_at, updated_at
+ FROM settings
+ ORDER BY id ASC
+ LIMIT 1
+ `)
+ return scanSetting(row)
+}
+
+func (r *SettingRepository) UpdateApplication(ctx context.Context, appName, apiURL string) error {
+ return r.update(ctx, `
+ UPDATE settings
+ SET app_name = ?, api_url = ?, updated_at = ?
+ WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1)
+ `, appName, apiURL)
+}
+
+func (r *SettingRepository) UpdateVersion(ctx context.Context, androidLatest, androidMin string, androidForce bool, iosLatest, iosMin string, iosForce bool) error {
+ return r.update(ctx, `
+ UPDATE settings
+ SET android_latest_version = ?,
+ android_min_version = ?,
+ android_force_update = ?,
+ ios_latest_version = ?,
+ ios_min_version = ?,
+ ios_force_update = ?,
+ updated_at = ?
+ WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1)
+ `, androidLatest, androidMin, boolToInt(androidForce), iosLatest, iosMin, boolToInt(iosForce))
+}
+
+func (r *SettingRepository) UpdatePlatformVersion(ctx context.Context, platform, latest, minimum string, force bool) error {
+ switch platform {
+ case "android":
+ return r.update(ctx, `
+ UPDATE settings
+ SET android_latest_version = ?, android_min_version = ?, android_force_update = ?, updated_at = ?
+ WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1)
+ `, latest, minimum, boolToInt(force))
+ case "ios":
+ return r.update(ctx, `
+ UPDATE settings
+ SET ios_latest_version = ?, ios_min_version = ?, ios_force_update = ?, updated_at = ?
+ WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1)
+ `, latest, minimum, boolToInt(force))
+ default:
+ return ErrSettingsNotFound
+ }
+}
+
+func (r *SettingRepository) UpdatePlatforms(ctx context.Context, androidEnabled, iosEnabled bool) error {
+ return r.update(ctx, `
+ UPDATE settings
+ SET android_enabled = ?, ios_enabled = ?, updated_at = ?
+ WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1)
+ `, boolToInt(androidEnabled), boolToInt(iosEnabled))
+}
+
+func (r *SettingRepository) UpdateMaintenance(ctx context.Context, enabled bool, message string) error {
+ return r.update(ctx, `
+ UPDATE settings
+ SET maintenance_mode = ?, maintenance_message = ?, updated_at = ?
+ WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1)
+ `, boolToInt(enabled), message)
+}
+
+func (r *SettingRepository) UpdateBanner(ctx context.Context, enabled bool, message string) error {
+ return r.update(ctx, `
+ UPDATE settings
+ SET banner_enabled = ?, banner_message = ?, updated_at = ?
+ WHERE id = (SELECT id FROM settings ORDER BY id ASC LIMIT 1)
+ `, boolToInt(enabled), message)
+}
+
+func (r *SettingRepository) update(ctx context.Context, query string, args ...any) error {
+ args = append(args, time.Now().UTC().Unix())
+ result, err := r.db.ExecContext(ctx, query, args...)
+ if err != nil {
+ return err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return ErrSettingsNotFound
+ }
+ return nil
+}
+
+func scanSetting(row *sql.Row) (*models.Setting, error) {
+ var setting models.Setting
+ var androidEnabled, androidForce, iosEnabled, iosForce, maintenanceMode, bannerEnabled int
+ var createdAt, updatedAt int64
+ if err := row.Scan(
+ &setting.ID,
+ &setting.AppName,
+ &androidEnabled,
+ &setting.AndroidLatestVersion,
+ &setting.AndroidMinVersion,
+ &androidForce,
+ &iosEnabled,
+ &setting.IOSLatestVersion,
+ &setting.IOSMinVersion,
+ &iosForce,
+ &maintenanceMode,
+ &setting.MaintenanceMessage,
+ &bannerEnabled,
+ &setting.BannerMessage,
+ &setting.APIURL,
+ &createdAt,
+ &updatedAt,
+ ); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrSettingsNotFound
+ }
+ return nil, err
+ }
+ setting.AndroidEnabled = androidEnabled != 0
+ setting.AndroidForceUpdate = androidForce != 0
+ setting.IOSEnabled = iosEnabled != 0
+ setting.IOSForceUpdate = iosForce != 0
+ setting.MaintenanceMode = maintenanceMode != 0
+ setting.BannerEnabled = bannerEnabled != 0
+ setting.CreatedAt = time.Unix(createdAt, 0).UTC()
+ setting.UpdatedAt = time.Unix(updatedAt, 0).UTC()
+ return &setting, nil
+}
+
+func boolToInt(v bool) int {
+ if v {
+ return 1
+ }
+ return 0
+}
diff --git a/internal/repositories/setting_repository_test.go b/internal/repositories/setting_repository_test.go
new file mode 100644
index 0000000..96d1b01
--- /dev/null
+++ b/internal/repositories/setting_repository_test.go
@@ -0,0 +1,95 @@
+package repositories
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+func TestSettingRepositoryLifecycle(t *testing.T) {
+ db := newTestDB(t)
+ repo := NewSettingRepository(db)
+ ctx := context.Background()
+
+ if count, err := repo.Count(ctx); err != nil || count != 0 {
+ t.Fatalf("expected empty settings count, got %d, %v", count, err)
+ }
+
+ now := time.Unix(1_700_000_100, 0).UTC()
+ id, err := repo.Create(ctx, models.Setting{
+ AndroidEnabled: true,
+ AppName: "GoAppMon",
+ AndroidLatestVersion: "1.0.0",
+ AndroidMinVersion: "1.0.0",
+ IOSEnabled: true,
+ IOSLatestVersion: "1.0.0",
+ IOSMinVersion: "1.0.0",
+ CreatedAt: now,
+ UpdatedAt: now,
+ })
+ if err != nil {
+ t.Fatalf("create settings: %v", err)
+ }
+ if id == 0 {
+ t.Fatal("expected non-zero id")
+ }
+
+ current, err := repo.GetCurrent(ctx)
+ if err != nil {
+ t.Fatalf("get current: %v", err)
+ }
+ if current.AppName != "GoAppMon" || !current.CreatedAt.Equal(now) || !current.UpdatedAt.Equal(now) {
+ t.Fatalf("unexpected settings: %+v", current)
+ }
+ if !current.AndroidEnabled || !current.IOSEnabled {
+ t.Fatalf("expected platforms enabled by default in test fixture: %+v", current)
+ }
+
+ if err := repo.UpdateApplication(ctx, "App Two", "https://api.example.com"); err != nil {
+ t.Fatalf("update application: %v", err)
+ }
+ if err := repo.UpdateVersion(ctx, "2.0.0", "1.5.0", true, "2.1.0", "1.5.1", false); err != nil {
+ t.Fatalf("update version: %v", err)
+ }
+ if err := repo.UpdateMaintenance(ctx, true, "maintenance"); err != nil {
+ t.Fatalf("update maintenance: %v", err)
+ }
+ if err := repo.UpdateBanner(ctx, true, "banner"); err != nil {
+ t.Fatalf("update banner: %v", err)
+ }
+
+ updated, err := repo.GetCurrent(ctx)
+ if err != nil {
+ t.Fatalf("get updated current: %v", err)
+ }
+ if updated.AppName != "App Two" || updated.APIURL != "https://api.example.com" {
+ t.Fatalf("unexpected updated app settings: %+v", updated)
+ }
+ if updated.AndroidLatestVersion != "2.0.0" || updated.AndroidMinVersion != "1.5.0" || !updated.AndroidForceUpdate {
+ t.Fatalf("unexpected android settings: %+v", updated)
+ }
+ if updated.IOSLatestVersion != "2.1.0" || updated.IOSMinVersion != "1.5.1" || updated.IOSForceUpdate {
+ t.Fatalf("unexpected ios settings: %+v", updated)
+ }
+ if !updated.MaintenanceMode || updated.MaintenanceMessage != "maintenance" {
+ t.Fatalf("unexpected maintenance settings: %+v", updated)
+ }
+ if !updated.BannerEnabled || updated.BannerMessage != "banner" {
+ t.Fatalf("unexpected banner settings: %+v", updated)
+ }
+ if updated.UpdatedAt.Equal(now) {
+ t.Fatalf("expected updated_at to change")
+ }
+}
+
+func TestSettingRepositoryUpdateMissing(t *testing.T) {
+ db := newTestDB(t)
+ repo := NewSettingRepository(db)
+ ctx := context.Background()
+
+ if err := repo.UpdateApplication(ctx, "App", ""); err != ErrSettingsNotFound {
+ t.Fatalf("expected ErrSettingsNotFound, got %v", err)
+ }
+}
diff --git a/internal/repositories/state_history_repository.go b/internal/repositories/state_history_repository.go
new file mode 100644
index 0000000..4e69687
--- /dev/null
+++ b/internal/repositories/state_history_repository.go
@@ -0,0 +1,123 @@
+package repositories
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+type StateHistoryRepository struct {
+ db DBTX
+}
+
+func NewStateHistoryRepository(db DBTX) *StateHistoryRepository {
+ return &StateHistoryRepository{db: db}
+}
+
+func (r *StateHistoryRepository) Create(ctx context.Context, change models.StateChange) (int64, error) {
+ result, err := r.db.ExecContext(ctx, `
+ INSERT INTO state_changes (
+ kind, enabled, message, created_by_admin_id, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ `,
+ change.Kind,
+ boolToInt(change.Enabled),
+ change.Message,
+ change.CreatedByAdminID,
+ change.CreatedAt.Unix(),
+ change.UpdatedAt.Unix(),
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+func (r *StateHistoryRepository) ListByKind(ctx context.Context, kind string, limit int) ([]models.StateChange, error) {
+ if limit <= 0 {
+ limit = 10
+ }
+ rows, err := r.db.QueryContext(ctx, `
+ SELECT id, kind, enabled, message, created_by_admin_id, created_at, updated_at
+ FROM state_changes
+ WHERE kind = ?
+ ORDER BY created_at DESC, id DESC
+ LIMIT ?
+ `, kind, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ changes := make([]models.StateChange, 0)
+ for rows.Next() {
+ change, err := scanStateChange(rows)
+ if err != nil {
+ return nil, err
+ }
+ changes = append(changes, *change)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return changes, nil
+}
+
+func (r *StateHistoryRepository) LatestByKind(ctx context.Context, kind string) (*models.StateChange, error) {
+ row := r.db.QueryRowContext(ctx, `
+ SELECT id, kind, enabled, message, created_by_admin_id, created_at, updated_at
+ FROM state_changes
+ WHERE kind = ?
+ ORDER BY created_at DESC, id DESC
+ LIMIT 1
+ `, kind)
+ return scanStateChangeRow(row)
+}
+
+func scanStateChange(rows *sql.Rows) (*models.StateChange, error) {
+ var change models.StateChange
+ var enabled int
+ var createdAt, updatedAt int64
+ if err := rows.Scan(
+ &change.ID,
+ &change.Kind,
+ &enabled,
+ &change.Message,
+ &change.CreatedByAdminID,
+ &createdAt,
+ &updatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ change.Enabled = enabled != 0
+ change.CreatedAt = time.Unix(createdAt, 0).UTC()
+ change.UpdatedAt = time.Unix(updatedAt, 0).UTC()
+ return &change, nil
+}
+
+func scanStateChangeRow(row *sql.Row) (*models.StateChange, error) {
+ var change models.StateChange
+ var enabled int
+ var createdAt, updatedAt int64
+ if err := row.Scan(
+ &change.ID,
+ &change.Kind,
+ &enabled,
+ &change.Message,
+ &change.CreatedByAdminID,
+ &createdAt,
+ &updatedAt,
+ ); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, sql.ErrNoRows
+ }
+ return nil, err
+ }
+ change.Enabled = enabled != 0
+ change.CreatedAt = time.Unix(createdAt, 0).UTC()
+ change.UpdatedAt = time.Unix(updatedAt, 0).UTC()
+ return &change, nil
+}
diff --git a/internal/repositories/version_release_repository.go b/internal/repositories/version_release_repository.go
new file mode 100644
index 0000000..e0b26d8
--- /dev/null
+++ b/internal/repositories/version_release_repository.go
@@ -0,0 +1,144 @@
+package repositories
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+type VersionReleaseRepository struct {
+ db DBTX
+}
+
+func NewVersionReleaseRepository(db DBTX) *VersionReleaseRepository {
+ return &VersionReleaseRepository{db: db}
+}
+
+func (r *VersionReleaseRepository) Create(ctx context.Context, release models.VersionRelease) (int64, error) {
+ result, err := r.db.ExecContext(ctx, `
+ INSERT INTO version_releases (
+ platform, latest_version, minimum_version, force_update, release_notes, created_by_admin_id, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ release.Platform,
+ release.LatestVersion,
+ release.MinimumVersion,
+ boolToInt(release.ForceUpdate),
+ release.ReleaseNotes,
+ release.CreatedByAdminID,
+ release.CreatedAt.Unix(),
+ release.UpdatedAt.Unix(),
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+func (r *VersionReleaseRepository) DeleteByID(ctx context.Context, id int64) error {
+ result, err := r.db.ExecContext(ctx, `DELETE FROM version_releases WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected == 0 {
+ return sql.ErrNoRows
+ }
+ return nil
+}
+
+func (r *VersionReleaseRepository) ListByPlatform(ctx context.Context, platform string, limit int) ([]models.VersionRelease, error) {
+ if limit <= 0 {
+ limit = 10
+ }
+ rows, err := r.db.QueryContext(ctx, `
+ SELECT id, platform, latest_version, minimum_version, force_update, release_notes, created_by_admin_id, created_at, updated_at
+ FROM version_releases
+ WHERE platform = ?
+ ORDER BY created_at DESC, id DESC
+ LIMIT ?
+ `, platform, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ releases := make([]models.VersionRelease, 0)
+ for rows.Next() {
+ entry, err := scanVersionRelease(rows)
+ if err != nil {
+ return nil, err
+ }
+ releases = append(releases, *entry)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return releases, nil
+}
+
+func (r *VersionReleaseRepository) LatestByPlatform(ctx context.Context, platform string) (*models.VersionRelease, error) {
+ row := r.db.QueryRowContext(ctx, `
+ SELECT id, platform, latest_version, minimum_version, force_update, release_notes, created_by_admin_id, created_at, updated_at
+ FROM version_releases
+ WHERE platform = ?
+ ORDER BY created_at DESC, id DESC
+ LIMIT 1
+ `, platform)
+ return scanVersionReleaseRow(row)
+}
+
+func scanVersionRelease(rows *sql.Rows) (*models.VersionRelease, error) {
+ var entry models.VersionRelease
+ var force int
+ var createdAt, updatedAt int64
+ if err := rows.Scan(
+ &entry.ID,
+ &entry.Platform,
+ &entry.LatestVersion,
+ &entry.MinimumVersion,
+ &force,
+ &entry.ReleaseNotes,
+ &entry.CreatedByAdminID,
+ &createdAt,
+ &updatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ entry.ForceUpdate = force != 0
+ entry.CreatedAt = time.Unix(createdAt, 0).UTC()
+ entry.UpdatedAt = time.Unix(updatedAt, 0).UTC()
+ return &entry, nil
+}
+
+func scanVersionReleaseRow(row *sql.Row) (*models.VersionRelease, error) {
+ var entry models.VersionRelease
+ var force int
+ var createdAt, updatedAt int64
+ if err := row.Scan(
+ &entry.ID,
+ &entry.Platform,
+ &entry.LatestVersion,
+ &entry.MinimumVersion,
+ &force,
+ &entry.ReleaseNotes,
+ &entry.CreatedByAdminID,
+ &createdAt,
+ &updatedAt,
+ ); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, sql.ErrNoRows
+ }
+ return nil, err
+ }
+ entry.ForceUpdate = force != 0
+ entry.CreatedAt = time.Unix(createdAt, 0).UTC()
+ entry.UpdatedAt = time.Unix(updatedAt, 0).UTC()
+ return &entry, nil
+}
diff --git a/internal/services/admin_service.go b/internal/services/admin_service.go
new file mode 100644
index 0000000..d402824
--- /dev/null
+++ b/internal/services/admin_service.go
@@ -0,0 +1,747 @@
+package services
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+ "github.com/phyowaiyan-dev/goappmon/internal/utils"
+ "github.com/shirou/gopsutil/v4/cpu"
+ "github.com/shirou/gopsutil/v4/disk"
+ "github.com/shirou/gopsutil/v4/host"
+ "github.com/shirou/gopsutil/v4/mem"
+)
+
+var ErrPlatformDisabled = errors.New("platform disabled")
+var ErrInvalidVersionFormat = errors.New("invalid version format")
+var ErrLatestVersionNotGreater = errors.New("latest version must be greater than current version")
+var ErrMinimumVersionGreaterThanLatest = errors.New("minimum version must be less than or equal to latest version")
+var ErrCannotDeleteLastVersion = errors.New("cannot delete last version")
+
+type ActionMeta struct {
+ ActorID int64
+ IP string
+ UserAgent string
+}
+
+type AdminDashboard struct {
+ Settings *models.Setting
+ Flags []models.FeatureFlag
+ AndroidReleases []models.VersionRelease
+ IOSReleases []models.VersionRelease
+ MaintenanceHistory []models.StateChange
+ BannerHistory []models.StateChange
+ AuditLogs []models.AuditLog
+ SystemHealth SystemHealth
+}
+
+type AuditLogSearchParams struct {
+ Query string
+ Page int
+ PageSize int
+}
+
+type AuditLogSearchResult struct {
+ Logs []models.AuditLog
+ Total int64
+ Page int
+ PageSize int
+ TotalPages int
+}
+
+type SystemHealth struct {
+ Score int
+ Status string
+ ScoreTone string
+ StatusTone string
+ Uptime string
+ SystemUptime string
+ GoVersion string
+ NumCPU int
+ CPUUsagePercent float64
+ NumGoroutine int
+ AllocMB float64
+ TotalAllocMB float64
+ SysMB float64
+ MemoryTotalMB float64
+ MemoryUsedMB float64
+ MemoryFreeMB float64
+ MemoryUsedPercent float64
+ DiskTotalGB float64
+ DiskUsedGB float64
+ DiskFreeGB float64
+ DiskUsedPercent float64
+ SQLiteFileSize string
+ SQLiteFilePath string
+ HealthNotes []string
+ UpdatedAt time.Time
+}
+
+type AdminService struct {
+ db *sql.DB
+ settings *repositories.SettingRepository
+ flags *repositories.FeatureFlagRepository
+ versions *repositories.VersionReleaseRepository
+ states *repositories.StateHistoryRepository
+ audits *repositories.AuditLogRepository
+ dbPath string
+ startedAt time.Time
+}
+
+func NewAdminService(db *sql.DB, settings *repositories.SettingRepository, flags *repositories.FeatureFlagRepository, dbPath string, startedAt time.Time) *AdminService {
+ return &AdminService{
+ db: db,
+ settings: settings,
+ flags: flags,
+ versions: repositories.NewVersionReleaseRepository(db),
+ states: repositories.NewStateHistoryRepository(db),
+ audits: repositories.NewAuditLogRepository(db),
+ dbPath: dbPath,
+ startedAt: startedAt,
+ }
+}
+
+func (s *AdminService) Dashboard(ctx context.Context) (*AdminDashboard, error) {
+ settings, err := s.settings.GetCurrent(ctx)
+ if err != nil {
+ return nil, err
+ }
+ flags, err := s.flags.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ androidReleases, err := s.versions.ListByPlatform(ctx, "android", 10)
+ if err != nil {
+ return nil, err
+ }
+ iosReleases, err := s.versions.ListByPlatform(ctx, "ios", 10)
+ if err != nil {
+ return nil, err
+ }
+ maintenanceHistory, err := s.states.ListByKind(ctx, "maintenance", 5)
+ if err != nil {
+ return nil, err
+ }
+ bannerHistory, err := s.states.ListByKind(ctx, "banner", 5)
+ if err != nil {
+ return nil, err
+ }
+ auditLogs, err := s.audits.ListRecent(ctx, 10)
+ if err != nil {
+ return nil, err
+ }
+ return &AdminDashboard{
+ Settings: settings,
+ Flags: flags,
+ AndroidReleases: androidReleases,
+ IOSReleases: iosReleases,
+ MaintenanceHistory: maintenanceHistory,
+ BannerHistory: bannerHistory,
+ AuditLogs: auditLogs,
+ SystemHealth: s.SystemHealth(),
+ }, nil
+}
+
+func (s *AdminService) SearchAuditLogs(ctx context.Context, params AuditLogSearchParams) (AuditLogSearchResult, error) {
+ if params.Page <= 0 {
+ params.Page = 1
+ }
+ if params.PageSize <= 0 {
+ params.PageSize = 20
+ }
+ filter := repositories.AuditLogFilter{
+ Query: params.Query,
+ Limit: params.PageSize,
+ Offset: (params.Page - 1) * params.PageSize,
+ }
+ page, err := s.audits.Search(ctx, filter)
+ if err != nil {
+ return AuditLogSearchResult{}, err
+ }
+ totalPages := 0
+ if page.Total > 0 {
+ totalPages = int((page.Total + int64(params.PageSize) - 1) / int64(params.PageSize))
+ }
+ if totalPages == 0 {
+ totalPages = 1
+ }
+ return AuditLogSearchResult{
+ Logs: page.Logs,
+ Total: page.Total,
+ Page: params.Page,
+ PageSize: params.PageSize,
+ TotalPages: totalPages,
+ }, nil
+}
+
+func (s *AdminService) UpdateApplication(ctx context.Context, meta ActionMeta, appName, apiURL string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ settings := repositories.NewSettingRepository(tx)
+ audits := repositories.NewAuditLogRepository(tx)
+ before, err := settings.GetCurrent(ctx)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := settings.UpdateApplication(ctx, appName, apiURL); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := createAudit(ctx, audits, meta, "settings.updated", "settings", "application", before, map[string]string{"app_name": appName, "api_url": apiURL}); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *AdminService) UpdatePlatforms(ctx context.Context, meta ActionMeta, androidEnabled, iosEnabled bool) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ settings := repositories.NewSettingRepository(tx)
+ audits := repositories.NewAuditLogRepository(tx)
+ before, err := settings.GetCurrent(ctx)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := settings.UpdatePlatforms(ctx, androidEnabled, iosEnabled); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := createAudit(ctx, audits, meta, "platforms.updated", "settings", "platforms", before, map[string]bool{"android_enabled": androidEnabled, "ios_enabled": iosEnabled}); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *AdminService) PublishVersion(ctx context.Context, meta ActionMeta, platform, latestVersion, minimumVersion string, forceUpdate bool, releaseNotes string) error {
+ if _, err := utils.ParseSemanticVersion(latestVersion); err != nil {
+ return ErrInvalidVersionFormat
+ }
+ if _, err := utils.ParseSemanticVersion(minimumVersion); err != nil {
+ return ErrInvalidVersionFormat
+ }
+ if cmp, err := utils.CompareSemanticVersion(minimumVersion, latestVersion); err != nil {
+ return ErrInvalidVersionFormat
+ } else if cmp > 0 {
+ return ErrMinimumVersionGreaterThanLatest
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ settings := repositories.NewSettingRepository(tx)
+ versions := repositories.NewVersionReleaseRepository(tx)
+ audits := repositories.NewAuditLogRepository(tx)
+ before, err := settings.GetCurrent(ctx)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ switch platform {
+ case "android":
+ if !before.AndroidEnabled {
+ _ = tx.Rollback()
+ return ErrPlatformDisabled
+ }
+ case "ios":
+ if !before.IOSEnabled {
+ _ = tx.Rollback()
+ return ErrPlatformDisabled
+ }
+ }
+ if latest, err := versions.LatestByPlatform(ctx, platform); err == nil && latest != nil {
+ if cmp, err := utils.CompareSemanticVersion(latestVersion, latest.LatestVersion); err != nil {
+ _ = tx.Rollback()
+ return ErrInvalidVersionFormat
+ } else if cmp <= 0 {
+ _ = tx.Rollback()
+ return ErrLatestVersionNotGreater
+ }
+ }
+ now := time.Now().UTC()
+ release := models.VersionRelease{
+ Platform: platform,
+ LatestVersion: latestVersion,
+ MinimumVersion: minimumVersion,
+ ForceUpdate: forceUpdate,
+ ReleaseNotes: releaseNotes,
+ CreatedByAdminID: meta.ActorID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if _, err := versions.Create(ctx, release); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := settings.UpdatePlatformVersion(ctx, platform, latestVersion, minimumVersion, forceUpdate); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ payload := map[string]any{
+ "platform": platform,
+ "latest_version": latestVersion,
+ "minimum_version": minimumVersion,
+ "force_update": forceUpdate,
+ "release_notes": releaseNotes,
+ }
+ if err := createAudit(ctx, audits, meta, "version.created", "version_release", platform, before, payload); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *AdminService) DeleteCurrentVersion(ctx context.Context, meta ActionMeta, platform string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ settings := repositories.NewSettingRepository(tx)
+ versions := repositories.NewVersionReleaseRepository(tx)
+ audits := repositories.NewAuditLogRepository(tx)
+ before, err := settings.GetCurrent(ctx)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if platform != "android" && platform != "ios" {
+ _ = tx.Rollback()
+ return fmt.Errorf("invalid platform")
+ }
+
+ releases, err := versions.ListByPlatform(ctx, platform, 2)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if len(releases) <= 1 {
+ _ = tx.Rollback()
+ return ErrCannotDeleteLastVersion
+ }
+ deleted := releases[0]
+ promoted := releases[1]
+
+ if err := versions.DeleteByID(ctx, deleted.ID); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := settings.UpdatePlatformVersion(ctx, platform, promoted.LatestVersion, promoted.MinimumVersion, promoted.ForceUpdate); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ payload := map[string]any{
+ "platform": platform,
+ "deleted_version": deleted.LatestVersion,
+ "promoted_version": promoted.LatestVersion,
+ }
+ if err := createAudit(ctx, audits, meta, "version.deleted", "version_release", platform, before, payload); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *AdminService) UpdateMaintenance(ctx context.Context, meta ActionMeta, enabled bool, message string) error {
+ return s.updateState(ctx, meta, "maintenance", "maintenance.updated", enabled, message)
+}
+
+func (s *AdminService) UpdateBanner(ctx context.Context, meta ActionMeta, enabled bool, message string) error {
+ return s.updateState(ctx, meta, "banner", "banner.updated", enabled, message)
+}
+
+func (s *AdminService) updateState(ctx context.Context, meta ActionMeta, kind, action string, enabled bool, message string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ settings := repositories.NewSettingRepository(tx)
+ states := repositories.NewStateHistoryRepository(tx)
+ audits := repositories.NewAuditLogRepository(tx)
+ before, err := settings.GetCurrent(ctx)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if !enabled {
+ message = ""
+ }
+ now := time.Now().UTC()
+ change := models.StateChange{
+ Kind: kind,
+ Enabled: enabled,
+ Message: message,
+ CreatedByAdminID: meta.ActorID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if _, err := states.Create(ctx, change); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ switch kind {
+ case "maintenance":
+ if err := settings.UpdateMaintenance(ctx, enabled, message); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ case "banner":
+ if err := settings.UpdateBanner(ctx, enabled, message); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ }
+ payload := map[string]any{
+ "enabled": enabled,
+ "message": message,
+ "kind": kind,
+ }
+ if err := createAudit(ctx, audits, meta, action, "state_change", kind, before, payload); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *AdminService) CreateFlag(ctx context.Context, meta ActionMeta, key string, enabled bool) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ flags := repositories.NewFeatureFlagRepository(tx)
+ audits := repositories.NewAuditLogRepository(tx)
+ id, err := flags.Create(ctx, key, enabled)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ flag, err := flags.GetByID(ctx, id)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := createAudit(ctx, audits, meta, "flag.created", "feature_flag", fmt.Sprintf("%d", id), nil, flag); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *AdminService) UpdateFlag(ctx context.Context, meta ActionMeta, id int64, key string, enabled bool) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ flags := repositories.NewFeatureFlagRepository(tx)
+ audits := repositories.NewAuditLogRepository(tx)
+ before, err := flags.GetByID(ctx, id)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := flags.Update(ctx, id, key, enabled); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := createAudit(ctx, audits, meta, "flag.updated", "feature_flag", fmt.Sprintf("%d", id), before, map[string]any{"key": key, "enabled": enabled}); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *AdminService) DeleteFlag(ctx context.Context, meta ActionMeta, id int64) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ flags := repositories.NewFeatureFlagRepository(tx)
+ audits := repositories.NewAuditLogRepository(tx)
+ before, err := flags.GetByID(ctx, id)
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := flags.Delete(ctx, id); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ if err := createAudit(ctx, audits, meta, "flag.deleted", "feature_flag", fmt.Sprintf("%d", id), before, nil); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *AdminService) SystemHealth() SystemHealth {
+ var runtimeMem runtime.MemStats
+ runtime.ReadMemStats(&runtimeMem)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
+ defer cancel()
+
+ dbSize := s.sqliteFileSize()
+ notes := make([]string, 0, 6)
+
+ memInfo, memErr := mem.VirtualMemoryWithContext(ctx)
+ diskInfo, diskErr := disk.UsageWithContext(ctx, s.diskUsagePath())
+ cpuUsage := 0.0
+ if cpuPercents, err := cpu.PercentWithContext(ctx, 200*time.Millisecond, false); err == nil && len(cpuPercents) > 0 {
+ cpuUsage = cpuPercents[0]
+ }
+ systemUptime := ""
+ if uptimeSeconds, err := host.UptimeWithContext(ctx); err == nil && uptimeSeconds > 0 {
+ systemUptime = (time.Duration(uptimeSeconds) * time.Second).Truncate(time.Second).String()
+ }
+ if memErr != nil {
+ notes = append(notes, "System memory snapshot is unavailable")
+ }
+ if diskErr != nil {
+ notes = append(notes, "Disk usage snapshot is unavailable")
+ }
+
+ score, status, scoreNotes := calculateHealthScore(cpuUsage, memInfo, memErr, diskInfo, diskErr, runtime.NumGoroutine(), dbSize, runtimeMem.Alloc)
+ notes = append(notes, scoreNotes...)
+
+ return SystemHealth{
+ Score: score,
+ Status: status,
+ ScoreTone: healthTone(status),
+ StatusTone: healthTone(status),
+ Uptime: time.Since(s.startedAt).Truncate(time.Second).String(),
+ SystemUptime: systemUptime,
+ GoVersion: runtime.Version(),
+ NumCPU: runtime.NumCPU(),
+ CPUUsagePercent: cpuUsage,
+ NumGoroutine: runtime.NumGoroutine(),
+ AllocMB: bytesToMB(runtimeMem.Alloc),
+ TotalAllocMB: bytesToMB(runtimeMem.TotalAlloc),
+ SysMB: bytesToMB(runtimeMem.Sys),
+ MemoryTotalMB: func() float64 {
+ if memErr != nil {
+ return 0
+ }
+ return bytesToMB(memInfo.Total)
+ }(),
+ MemoryUsedMB: func() float64 {
+ if memErr != nil {
+ return 0
+ }
+ return bytesToMB(memInfo.Used)
+ }(),
+ MemoryFreeMB: func() float64 {
+ if memErr != nil {
+ return 0
+ }
+ return bytesToMB(memInfo.Available)
+ }(),
+ MemoryUsedPercent: func() float64 {
+ if memErr != nil {
+ return 0
+ }
+ return memInfo.UsedPercent
+ }(),
+ DiskTotalGB: func() float64 {
+ if diskErr != nil {
+ return 0
+ }
+ return bytesToGB(diskInfo.Total)
+ }(),
+ DiskUsedGB: func() float64 {
+ if diskErr != nil {
+ return 0
+ }
+ return bytesToGB(diskInfo.Used)
+ }(),
+ DiskFreeGB: func() float64 {
+ if diskErr != nil {
+ return 0
+ }
+ return bytesToGB(diskInfo.Free)
+ }(),
+ DiskUsedPercent: func() float64 {
+ if diskErr != nil {
+ return 0
+ }
+ return diskInfo.UsedPercent
+ }(),
+ SQLiteFileSize: humanFileSize(dbSize),
+ SQLiteFilePath: s.dbPath,
+ HealthNotes: notes,
+ UpdatedAt: time.Now().UTC(),
+ }
+}
+
+func calculateHealthScore(cpuUsage float64, memInfo *mem.VirtualMemoryStat, memErr error, diskInfo *disk.UsageStat, diskErr error, goroutines int, dbSize int64, appAllocBytes uint64) (int, string, []string) {
+ score := 96.0
+ notes := make([]string, 0, 6)
+
+ if memErr == nil && memInfo != nil {
+ switch {
+ case memInfo.UsedPercent > 90:
+ score -= (memInfo.UsedPercent - 70) * 1.1
+ notes = append(notes, "Memory is in a critical zone")
+ case memInfo.UsedPercent > 70:
+ score -= (memInfo.UsedPercent - 70) * 0.9
+ }
+ if memInfo.Available < 1024*1024*1024 {
+ score -= 6
+ notes = append(notes, "Available memory is below 1 GB")
+ }
+ }
+
+ if diskErr == nil && diskInfo != nil {
+ switch {
+ case diskInfo.UsedPercent > 95:
+ score -= (diskInfo.UsedPercent - 75) * 1.25
+ notes = append(notes, "Disk usage is critically high")
+ case diskInfo.UsedPercent > 75:
+ score -= (diskInfo.UsedPercent - 75) * 1.25
+ }
+ if diskInfo.Free < 10*1024*1024*1024 {
+ score -= 6
+ notes = append(notes, "Disk free space is below 10 GB")
+ }
+ }
+
+ if cpuUsage > 80 {
+ score -= (cpuUsage - 80) * 0.35
+ notes = append(notes, "CPU usage is elevated")
+ } else if cpuUsage > 55 {
+ score -= (cpuUsage - 55) * 0.22
+ }
+
+ if goroutines > 75 {
+ score -= 2
+ }
+ if dbSize > 100*1024*1024 {
+ score -= 4
+ }
+ if appAllocBytes > 256*1024*1024 {
+ score -= 4
+ }
+
+ if score > 96 {
+ score = 96
+ }
+ if score < 36 {
+ score = 36
+ }
+
+ status := "Healthy"
+ switch {
+ case score >= 90:
+ status = "Excellent"
+ case score >= 75:
+ status = "Good"
+ case score >= 55:
+ status = "Fair"
+ default:
+ status = "Needs attention"
+ }
+
+ return int(score + 0.5), status, notes
+}
+
+func (s *AdminService) sqliteFileSize() int64 {
+ if s.dbPath == "" {
+ return 0
+ }
+ info, err := os.Stat(s.dbPath)
+ if err != nil {
+ return 0
+ }
+ return info.Size()
+}
+
+func (s *AdminService) diskUsagePath() string {
+ if s.dbPath == "" {
+ return "."
+ }
+ dir := filepath.Dir(s.dbPath)
+ if dir == "" || dir == "." {
+ return s.dbPath
+ }
+ return dir
+}
+
+func createAudit(ctx context.Context, repo *repositories.AuditLogRepository, meta ActionMeta, action, entityType, entityID string, before, after any) error {
+ entry := models.AuditLog{
+ ActorAdminID: meta.ActorID,
+ Action: action,
+ EntityType: entityType,
+ EntityID: entityID,
+ IP: meta.IP,
+ UserAgent: meta.UserAgent,
+ CreatedAt: time.Now().UTC(),
+ }
+ if before != nil {
+ data, err := json.Marshal(before)
+ if err != nil {
+ return err
+ }
+ entry.BeforeJSON = string(data)
+ }
+ if after != nil {
+ data, err := json.Marshal(after)
+ if err != nil {
+ return err
+ }
+ entry.AfterJSON = string(data)
+ }
+ _, err := repo.Create(ctx, entry)
+ return err
+}
+
+func bytesToMB(v uint64) float64 {
+ return float64(v) / (1024 * 1024)
+}
+
+func bytesToGB(v uint64) float64 {
+ return float64(v) / (1024 * 1024 * 1024)
+}
+
+func humanFileSize(bytes int64) string {
+ if bytes < 1024 {
+ return fmt.Sprintf("%d B", bytes)
+ }
+ unit := []string{"KB", "MB", "GB", "TB"}
+ size := float64(bytes)
+ i := 0
+ for size >= 1024 && i < len(unit)-1 {
+ size /= 1024
+ i++
+ }
+ return fmt.Sprintf("%.1f %s", size, unit[i])
+}
+
+func healthTone(status string) string {
+ switch strings.ToLower(status) {
+ case "healthy":
+ return "success"
+ case "excellent":
+ return "success"
+ case "good":
+ return "info"
+ case "fair":
+ return "warning"
+ default:
+ return "error"
+ }
+}
diff --git a/internal/services/admin_service_test.go b/internal/services/admin_service_test.go
new file mode 100644
index 0000000..6f70de0
--- /dev/null
+++ b/internal/services/admin_service_test.go
@@ -0,0 +1,194 @@
+package services
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/database"
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+ "github.com/shirou/gopsutil/v4/disk"
+ "github.com/shirou/gopsutil/v4/mem"
+)
+
+func TestAdminServiceHistoryAndAuditTrail(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "goappmon.sqlite")
+ db, err := database.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := database.Migrate(context.Background(), db); err != nil {
+ t.Fatalf("migrate db: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = db.Close()
+ })
+
+ ctx := context.Background()
+ settingsRepo := repositories.NewSettingRepository(db)
+ flagsRepo := repositories.NewFeatureFlagRepository(db)
+ now := time.Unix(1_700_000_500, 0).UTC()
+ if _, err := settingsRepo.Create(ctx, models.Setting{
+ AppName: "GoAppMon",
+ AndroidEnabled: true,
+ AndroidLatestVersion: "1.0.0",
+ AndroidMinVersion: "1.0.0",
+ IOSEnabled: true,
+ IOSLatestVersion: "1.0.0",
+ IOSMinVersion: "1.0.0",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("create settings: %v", err)
+ }
+
+ service := NewAdminService(db, settingsRepo, flagsRepo, dbPath, now)
+ meta := ActionMeta{ActorID: 42, IP: "127.0.0.1", UserAgent: "codex-test"}
+
+ if err := service.PublishVersion(ctx, meta, "android", "2.0.0", "1.5.0", true, "android release"); err != nil {
+ t.Fatalf("publish android version: %v", err)
+ }
+ if err := service.UpdateMaintenance(ctx, meta, true, "maintenance"); err != nil {
+ t.Fatalf("update maintenance: %v", err)
+ }
+ if err := service.UpdateBanner(ctx, meta, true, "banner"); err != nil {
+ t.Fatalf("update banner: %v", err)
+ }
+ if err := service.CreateFlag(ctx, meta, "chat", true); err != nil {
+ t.Fatalf("create flag: %v", err)
+ }
+
+ dashboard, err := service.Dashboard(ctx)
+ if err != nil {
+ t.Fatalf("dashboard: %v", err)
+ }
+ if len(dashboard.AndroidReleases) != 1 {
+ t.Fatalf("expected 1 android release, got %d", len(dashboard.AndroidReleases))
+ }
+ if len(dashboard.MaintenanceHistory) != 1 || len(dashboard.BannerHistory) != 1 {
+ t.Fatalf("expected history entries, got maintenance=%d banner=%d", len(dashboard.MaintenanceHistory), len(dashboard.BannerHistory))
+ }
+ if len(dashboard.AuditLogs) == 0 {
+ t.Fatal("expected audit log entries")
+ }
+ if dashboard.SystemHealth.Score < 36 || dashboard.SystemHealth.Score > 96 {
+ t.Fatalf("expected bounded health score, got %d", dashboard.SystemHealth.Score)
+ }
+ if dashboard.SystemHealth.MemoryTotalMB < 0 || dashboard.SystemHealth.DiskTotalGB < 0 {
+ t.Fatalf("expected non-negative machine metrics, got %+v", dashboard.SystemHealth)
+ }
+}
+
+func TestAdminServiceVersionValidationAndDeleteLatest(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "goappmon.sqlite")
+ db, err := database.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := database.Migrate(context.Background(), db); err != nil {
+ t.Fatalf("migrate db: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = db.Close()
+ })
+
+ ctx := context.Background()
+ settingsRepo := repositories.NewSettingRepository(db)
+ flagsRepo := repositories.NewFeatureFlagRepository(db)
+ now := time.Unix(1_700_000_500, 0).UTC()
+ if _, err := settingsRepo.Create(ctx, models.Setting{
+ AppName: "GoAppMon",
+ AndroidEnabled: true,
+ AndroidLatestVersion: "1.0.0",
+ AndroidMinVersion: "1.0.0",
+ IOSEnabled: true,
+ IOSLatestVersion: "1.0.0",
+ IOSMinVersion: "1.0.0",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("create settings: %v", err)
+ }
+
+ service := NewAdminService(db, settingsRepo, flagsRepo, dbPath, now)
+ meta := ActionMeta{ActorID: 42, IP: "127.0.0.1", UserAgent: "codex-test"}
+
+ if err := service.PublishVersion(ctx, meta, "android", "1.0.0", "1.0.0", false, "seed"); err != nil {
+ t.Fatalf("publish android seed version: %v", err)
+ }
+ if err := service.PublishVersion(ctx, meta, "android", "2.0.0", "1.5.0", true, "android release"); err != nil {
+ t.Fatalf("publish android latest version: %v", err)
+ }
+ if err := service.PublishVersion(ctx, meta, "ios", "1.0.0", "1.0.0", false, "seed"); err != nil {
+ t.Fatalf("publish ios seed version: %v", err)
+ }
+ if err := service.PublishVersion(ctx, meta, "ios", "2.0.0", "1.5.0", true, "ios release"); err != nil {
+ t.Fatalf("publish ios latest version: %v", err)
+ }
+
+ if err := service.PublishVersion(ctx, meta, "android", "2.0", "1.5.0", false, "bad"); !errors.Is(err, ErrInvalidVersionFormat) {
+ t.Fatalf("expected invalid version format, got %v", err)
+ }
+ if err := service.PublishVersion(ctx, meta, "android", "2.0.0", "2.1.0", false, "bad"); !errors.Is(err, ErrMinimumVersionGreaterThanLatest) {
+ t.Fatalf("expected minimum greater than latest, got %v", err)
+ }
+ if err := service.PublishVersion(ctx, meta, "android", "2.0.0", "1.5.0", false, "bad"); !errors.Is(err, ErrLatestVersionNotGreater) {
+ t.Fatalf("expected latest not increasing, got %v", err)
+ }
+
+ if err := service.DeleteCurrentVersion(ctx, meta, "android"); err != nil {
+ t.Fatalf("delete android latest version: %v", err)
+ }
+ if err := service.DeleteCurrentVersion(ctx, meta, "ios"); err != nil {
+ t.Fatalf("delete ios latest version: %v", err)
+ }
+
+ dashboard, err := service.Dashboard(ctx)
+ if err != nil {
+ t.Fatalf("dashboard: %v", err)
+ }
+ if len(dashboard.AndroidReleases) != 1 {
+ t.Fatalf("expected 1 android release after delete, got %d", len(dashboard.AndroidReleases))
+ }
+ if dashboard.Settings.AndroidLatestVersion != "1.0.0" {
+ t.Fatalf("expected android latest version to roll back, got %s", dashboard.Settings.AndroidLatestVersion)
+ }
+ if len(dashboard.IOSReleases) != 1 {
+ t.Fatalf("expected 1 ios release after delete, got %d", len(dashboard.IOSReleases))
+ }
+ if dashboard.Settings.IOSLatestVersion != "1.0.0" {
+ t.Fatalf("expected ios latest version to roll back, got %s", dashboard.Settings.IOSLatestVersion)
+ }
+
+ if err := service.DeleteCurrentVersion(ctx, meta, "android"); !errors.Is(err, ErrCannotDeleteLastVersion) {
+ t.Fatalf("expected cannot delete last version, got %v", err)
+ }
+}
+
+func TestCalculateHealthScorePenaltyProfile(t *testing.T) {
+ score, status, notes := calculateHealthScore(
+ 73.1,
+ &mem.VirtualMemoryStat{UsedPercent: 85.1, Available: 1221 * 1024 * 1024},
+ nil,
+ &disk.UsageStat{UsedPercent: 97.3, Free: 6 * 1024 * 1024 * 1024},
+ nil,
+ 7,
+ 56*1024*1024,
+ 4*1024*1024,
+ )
+
+ if score >= 70 {
+ t.Fatalf("expected heavy pressure score to be low, got %d", score)
+ }
+ if status != "Needs attention" {
+ t.Fatalf("expected needs attention status, got %q", status)
+ }
+ if len(notes) == 0 {
+ t.Fatal("expected warning notes for heavy pressure")
+ }
+}
diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go
new file mode 100644
index 0000000..5bd9a82
--- /dev/null
+++ b/internal/services/auth_service.go
@@ -0,0 +1,49 @@
+package services
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+ "github.com/phyowaiyan-dev/goappmon/internal/utils"
+)
+
+var ErrInvalidCredentials = errors.New("invalid credentials")
+
+type AuthService struct {
+ admins *repositories.AdminRepository
+ sessionSecret []byte
+ sessionTTL time.Duration
+}
+
+func NewAuthService(admins *repositories.AdminRepository, sessionSecret []byte, ttl time.Duration) *AuthService {
+ return &AuthService{admins: admins, sessionSecret: sessionSecret, sessionTTL: ttl}
+}
+
+func (s *AuthService) Authenticate(ctx context.Context, email, password string) (*models.Admin, error) {
+ admin, err := s.admins.GetByEmail(ctx, email)
+ if err != nil {
+ if errors.Is(err, repositories.ErrAdminNotFound) {
+ return nil, ErrInvalidCredentials
+ }
+ return nil, err
+ }
+ if err := utils.CheckPassword(admin.PasswordHash, password); err != nil {
+ return nil, ErrInvalidCredentials
+ }
+ return admin, nil
+}
+
+func (s *AuthService) SignSession(adminID int64) (string, error) {
+ return utils.SignSession(s.sessionSecret, adminID, s.sessionTTL)
+}
+
+func (s *AuthService) VerifySession(token string) (int64, error) {
+ claims, err := utils.VerifySession(s.sessionSecret, token)
+ if err != nil {
+ return 0, err
+ }
+ return claims.AdminID, nil
+}
diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go
new file mode 100644
index 0000000..5cf6786
--- /dev/null
+++ b/internal/services/auth_service_test.go
@@ -0,0 +1,62 @@
+package services
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+ "github.com/phyowaiyan-dev/goappmon/internal/utils"
+)
+
+func TestAuthServiceAuthenticateAndSession(t *testing.T) {
+ db := repositoriesTestDB(t)
+ adminRepo := repositories.NewAdminRepository(db)
+
+ hash, err := utils.HashPassword("secret123")
+ if err != nil {
+ t.Fatalf("hash password: %v", err)
+ }
+
+ _, err = adminRepo.Create(context.Background(), models.Admin{
+ Name: "Admin",
+ Email: "admin@example.com",
+ PasswordHash: hash,
+ })
+ if err != nil {
+ t.Fatalf("create admin: %v", err)
+ }
+
+ service := NewAuthService(adminRepo, []byte("01234567890123456789012345678901"), time.Hour)
+ admin, err := service.Authenticate(context.Background(), "admin@example.com", "secret123")
+ if err != nil {
+ t.Fatalf("authenticate: %v", err)
+ }
+ if admin.Email != "admin@example.com" {
+ t.Fatalf("unexpected admin: %+v", admin)
+ }
+
+ if _, err := service.Authenticate(context.Background(), "admin@example.com", "bad"); err != ErrInvalidCredentials {
+ t.Fatalf("expected invalid credentials, got %v", err)
+ }
+ if _, err := service.Authenticate(context.Background(), "missing@example.com", "secret123"); err != ErrInvalidCredentials {
+ t.Fatalf("expected invalid credentials for missing admin, got %v", err)
+ }
+
+ token, err := service.SignSession(admin.ID)
+ if err != nil {
+ t.Fatalf("sign session: %v", err)
+ }
+ adminID, err := service.VerifySession(token)
+ if err != nil {
+ t.Fatalf("verify session: %v", err)
+ }
+ if adminID != admin.ID {
+ t.Fatalf("unexpected admin id: %d", adminID)
+ }
+
+ if _, err := service.VerifySession(token + "tamper"); err == nil {
+ t.Fatal("expected tampered session to fail")
+ }
+}
diff --git a/internal/services/setup_service.go b/internal/services/setup_service.go
new file mode 100644
index 0000000..b05c676
--- /dev/null
+++ b/internal/services/setup_service.go
@@ -0,0 +1,116 @@
+package services
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+ "github.com/phyowaiyan-dev/goappmon/internal/utils"
+)
+
+var ErrSetupAlreadyComplete = errors.New("setup already complete")
+
+type SetupService struct {
+ db *sql.DB
+}
+
+func NewSetupService(db *sql.DB) *SetupService {
+ return &SetupService{db: db}
+}
+
+func (s *SetupService) IsSetupComplete(ctx context.Context) (bool, error) {
+ count, err := repositories.NewAdminRepository(s.db).Count(ctx)
+ if err != nil {
+ return false, err
+ }
+ return count > 0, nil
+}
+
+func (s *SetupService) EnsureDefaultSettings(ctx context.Context) error {
+ adminCount, err := repositories.NewAdminRepository(s.db).Count(ctx)
+ if err != nil {
+ return err
+ }
+ if adminCount == 0 {
+ return nil
+ }
+
+ repo := repositories.NewSettingRepository(s.db)
+ count, err := repo.Count(ctx)
+ if err != nil {
+ return err
+ }
+ if count > 0 {
+ return nil
+ }
+
+ now := time.Now().UTC()
+ _, err = repo.Create(ctx, models.Setting{
+ AppName: "GoAppMon",
+ AndroidEnabled: true,
+ AndroidLatestVersion: "1.0.0",
+ AndroidMinVersion: "1.0.0",
+ IOSEnabled: true,
+ IOSLatestVersion: "1.0.0",
+ IOSMinVersion: "1.0.0",
+ CreatedAt: now,
+ UpdatedAt: now,
+ })
+ return err
+}
+
+func (s *SetupService) CreateInitialSetup(ctx context.Context, adminName, adminEmail, password, appName string) (err error) {
+ adminRepo := repositories.NewAdminRepository(s.db)
+ count, err := adminRepo.Count(ctx)
+ if err != nil {
+ return err
+ }
+ if count > 0 {
+ return ErrSetupAlreadyComplete
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ _ = tx.Rollback()
+ }
+ }()
+
+ txAdmins := repositories.NewAdminRepository(tx)
+ txSettings := repositories.NewSettingRepository(tx)
+ hash, err := utils.HashPassword(password)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now().UTC()
+ if _, err = txAdmins.Create(ctx, models.Admin{
+ Name: adminName,
+ Email: adminEmail,
+ PasswordHash: hash,
+ CreatedAt: now,
+ }); err != nil {
+ return err
+ }
+ if _, err = txSettings.Create(ctx, models.Setting{
+ AppName: appName,
+ AndroidEnabled: true,
+ AndroidLatestVersion: "1.0.0",
+ AndroidMinVersion: "1.0.0",
+ IOSEnabled: true,
+ IOSLatestVersion: "1.0.0",
+ IOSMinVersion: "1.0.0",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
diff --git a/internal/services/setup_service_test.go b/internal/services/setup_service_test.go
new file mode 100644
index 0000000..2daf01e
--- /dev/null
+++ b/internal/services/setup_service_test.go
@@ -0,0 +1,89 @@
+package services
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+)
+
+func TestSetupServiceWorkflow(t *testing.T) {
+ db := repositoriesTestDB(t)
+ service := NewSetupService(db)
+ ctx := context.Background()
+
+ complete, err := service.IsSetupComplete(ctx)
+ if err != nil {
+ t.Fatalf("is setup complete: %v", err)
+ }
+ if complete {
+ t.Fatal("expected setup to be incomplete")
+ }
+
+ if err := service.EnsureDefaultSettings(ctx); err != nil {
+ t.Fatalf("ensure default settings with no admin: %v", err)
+ }
+
+ adminRepo := repositories.NewAdminRepository(db)
+ if _, err := adminRepo.Create(ctx, models.Admin{
+ Name: "Existing Admin",
+ Email: "existing@example.com",
+ PasswordHash: "hash",
+ CreatedAt: time.Unix(1_700_000_200, 0).UTC(),
+ }); err != nil {
+ t.Fatalf("create admin: %v", err)
+ }
+
+ if err := service.EnsureDefaultSettings(ctx); err != nil {
+ t.Fatalf("ensure default settings with admin: %v", err)
+ }
+
+ settingsRepo := repositories.NewSettingRepository(db)
+ current, err := settingsRepo.GetCurrent(ctx)
+ if err != nil {
+ t.Fatalf("get current settings: %v", err)
+ }
+ if current.AppName != "GoAppMon" {
+ t.Fatalf("unexpected default app name: %s", current.AppName)
+ }
+
+ // A separate database validates the full initial setup flow without
+ // interference from the direct admin insert above.
+ db2 := repositoriesTestDB(t)
+ service2 := NewSetupService(db2)
+ if err := service2.CreateInitialSetup(ctx, "First Admin", "first@example.com", "secret123", "Control Center"); err != nil {
+ t.Fatalf("create initial setup: %v", err)
+ }
+
+ complete, err = service2.IsSetupComplete(ctx)
+ if err != nil {
+ t.Fatalf("is setup complete after setup: %v", err)
+ }
+ if !complete {
+ t.Fatal("expected setup to be complete")
+ }
+
+ adminRepo2 := repositories.NewAdminRepository(db2)
+ admin, err := adminRepo2.GetByEmail(ctx, "first@example.com")
+ if err != nil {
+ t.Fatalf("get first admin: %v", err)
+ }
+ if admin.Name != "First Admin" {
+ t.Fatalf("unexpected first admin: %+v", admin)
+ }
+
+ settingsRepo2 := repositories.NewSettingRepository(db2)
+ current2, err := settingsRepo2.GetCurrent(ctx)
+ if err != nil {
+ t.Fatalf("get setup settings: %v", err)
+ }
+ if current2.AppName != "Control Center" {
+ t.Fatalf("unexpected setup app name: %s", current2.AppName)
+ }
+
+ if err := service2.CreateInitialSetup(ctx, "Second", "second@example.com", "secret123", "Other"); err != ErrSetupAlreadyComplete {
+ t.Fatalf("expected ErrSetupAlreadyComplete, got %v", err)
+ }
+}
diff --git a/internal/services/status_service.go b/internal/services/status_service.go
new file mode 100644
index 0000000..4f20fe8
--- /dev/null
+++ b/internal/services/status_service.go
@@ -0,0 +1,89 @@
+package services
+
+import (
+ "context"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+)
+
+type StatusService struct {
+ settings *repositories.SettingRepository
+ flags *repositories.FeatureFlagRepository
+}
+
+type PublicStatus struct {
+ MaintenanceMode bool `json:"maintenance_mode"`
+ MaintenanceMessage string `json:"maintenance_message"`
+ BannerEnabled bool `json:"banner_enabled"`
+ BannerMessage string `json:"banner_message"`
+}
+
+type PublicVersion struct {
+ Android struct {
+ LatestVersion string `json:"latest_version"`
+ MinimumVersion string `json:"minimum_version"`
+ ForceUpdate bool `json:"force_update"`
+ } `json:"android"`
+ IOS struct {
+ LatestVersion string `json:"latest_version"`
+ MinimumVersion string `json:"minimum_version"`
+ ForceUpdate bool `json:"force_update"`
+ } `json:"ios"`
+}
+
+type PublicConfig struct {
+ AppName string `json:"app_name"`
+ APIURL string `json:"api_url"`
+}
+
+func NewStatusService(settings *repositories.SettingRepository, flags *repositories.FeatureFlagRepository) *StatusService {
+ return &StatusService{settings: settings, flags: flags}
+}
+
+func (s *StatusService) CurrentSettings(ctx context.Context) (*models.Setting, error) {
+ return s.settings.GetCurrent(ctx)
+}
+
+func (s *StatusService) PublicStatus(ctx context.Context) (PublicStatus, error) {
+ setting, err := s.settings.GetCurrent(ctx)
+ if err != nil {
+ return PublicStatus{}, err
+ }
+ return PublicStatus{
+ MaintenanceMode: setting.MaintenanceMode,
+ MaintenanceMessage: setting.MaintenanceMessage,
+ BannerEnabled: setting.BannerEnabled,
+ BannerMessage: setting.BannerMessage,
+ }, nil
+}
+
+func (s *StatusService) PublicVersion(ctx context.Context) (PublicVersion, error) {
+ setting, err := s.settings.GetCurrent(ctx)
+ if err != nil {
+ return PublicVersion{}, err
+ }
+ var version PublicVersion
+ version.Android.LatestVersion = setting.AndroidLatestVersion
+ version.Android.MinimumVersion = setting.AndroidMinVersion
+ version.Android.ForceUpdate = setting.AndroidForceUpdate
+ version.IOS.LatestVersion = setting.IOSLatestVersion
+ version.IOS.MinimumVersion = setting.IOSMinVersion
+ version.IOS.ForceUpdate = setting.IOSForceUpdate
+ return version, nil
+}
+
+func (s *StatusService) PublicConfig(ctx context.Context) (PublicConfig, error) {
+ setting, err := s.settings.GetCurrent(ctx)
+ if err != nil {
+ return PublicConfig{}, err
+ }
+ return PublicConfig{
+ AppName: setting.AppName,
+ APIURL: setting.APIURL,
+ }, nil
+}
+
+func (s *StatusService) PublicFeatureFlags(ctx context.Context) (map[string]bool, error) {
+ return s.flags.AsMap(ctx)
+}
diff --git a/internal/services/status_service_test.go b/internal/services/status_service_test.go
new file mode 100644
index 0000000..0e3e0f4
--- /dev/null
+++ b/internal/services/status_service_test.go
@@ -0,0 +1,78 @@
+package services
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+ "github.com/phyowaiyan-dev/goappmon/internal/repositories"
+)
+
+func TestStatusServicePublicViews(t *testing.T) {
+ db := repositoriesTestDB(t)
+ ctx := context.Background()
+ settingsRepo := repositories.NewSettingRepository(db)
+ flagsRepo := repositories.NewFeatureFlagRepository(db)
+
+ now := time.Unix(1_700_000_300, 0).UTC()
+ if _, err := settingsRepo.Create(ctx, models.Setting{
+ AndroidEnabled: true,
+ AppName: "GoAppMon",
+ AndroidLatestVersion: "1.2.0",
+ AndroidMinVersion: "1.0.0",
+ AndroidForceUpdate: true,
+ IOSEnabled: true,
+ IOSLatestVersion: "1.1.0",
+ IOSMinVersion: "1.0.0",
+ IOSForceUpdate: false,
+ MaintenanceMode: true,
+ MaintenanceMessage: "maintenance",
+ BannerEnabled: true,
+ BannerMessage: "banner",
+ APIURL: "https://api.example.com",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("create settings: %v", err)
+ }
+ if _, err := flagsRepo.Create(ctx, "chat", true); err != nil {
+ t.Fatalf("create feature flag: %v", err)
+ }
+ if _, err := flagsRepo.Create(ctx, "payment", false); err != nil {
+ t.Fatalf("create feature flag: %v", err)
+ }
+
+ service := NewStatusService(settingsRepo, flagsRepo)
+ status, err := service.PublicStatus(ctx)
+ if err != nil {
+ t.Fatalf("public status: %v", err)
+ }
+ if !status.MaintenanceMode || !status.BannerEnabled {
+ t.Fatalf("unexpected status: %+v", status)
+ }
+
+ version, err := service.PublicVersion(ctx)
+ if err != nil {
+ t.Fatalf("public version: %v", err)
+ }
+ if !version.Android.ForceUpdate || version.Android.LatestVersion != "1.2.0" {
+ t.Fatalf("unexpected version: %+v", version)
+ }
+
+ cfg, err := service.PublicConfig(ctx)
+ if err != nil {
+ t.Fatalf("public config: %v", err)
+ }
+ if cfg.AppName != "GoAppMon" || cfg.APIURL != "https://api.example.com" {
+ t.Fatalf("unexpected config: %+v", cfg)
+ }
+
+ flagMap, err := service.PublicFeatureFlags(ctx)
+ if err != nil {
+ t.Fatalf("public feature flags: %v", err)
+ }
+ if !flagMap["chat"] || flagMap["payment"] {
+ t.Fatalf("unexpected feature map: %#v", flagMap)
+ }
+}
diff --git a/internal/services/test_helpers_test.go b/internal/services/test_helpers_test.go
new file mode 100644
index 0000000..f9fc8c4
--- /dev/null
+++ b/internal/services/test_helpers_test.go
@@ -0,0 +1,36 @@
+package services
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "testing"
+
+ "github.com/phyowaiyan-dev/goappmon/internal/database"
+ "github.com/phyowaiyan-dev/goappmon/internal/models"
+)
+
+func repositoriesTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+
+ dbPath := filepath.Join(t.TempDir(), "test.sqlite")
+ db, err := database.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := database.Migrate(context.Background(), db); err != nil {
+ t.Fatalf("migrate db: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = db.Close()
+ })
+ return db
+}
+
+func adminRow(name, email, hash string) models.Admin {
+ return models.Admin{
+ Name: name,
+ Email: email,
+ PasswordHash: hash,
+ }
+}
diff --git a/internal/utils/password.go b/internal/utils/password.go
new file mode 100644
index 0000000..9b15df6
--- /dev/null
+++ b/internal/utils/password.go
@@ -0,0 +1,15 @@
+package utils
+
+import "golang.org/x/crypto/bcrypt"
+
+func HashPassword(password string) (string, error) {
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return "", err
+ }
+ return string(hash), nil
+}
+
+func CheckPassword(hash, password string) error {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+}
diff --git a/internal/utils/password_test.go b/internal/utils/password_test.go
new file mode 100644
index 0000000..06df87d
--- /dev/null
+++ b/internal/utils/password_test.go
@@ -0,0 +1,16 @@
+package utils
+
+import "testing"
+
+func TestHashPasswordAndCheckPassword(t *testing.T) {
+ hash, err := HashPassword("secret123")
+ if err != nil {
+ t.Fatalf("hash password: %v", err)
+ }
+ if err := CheckPassword(hash, "secret123"); err != nil {
+ t.Fatalf("check password: %v", err)
+ }
+ if err := CheckPassword(hash, "wrong"); err == nil {
+ t.Fatal("expected wrong password to fail")
+ }
+}
diff --git a/internal/utils/response.go b/internal/utils/response.go
new file mode 100644
index 0000000..1f77428
--- /dev/null
+++ b/internal/utils/response.go
@@ -0,0 +1,15 @@
+package utils
+
+import "github.com/gin-gonic/gin"
+
+type ErrorResponse struct {
+ Error string `json:"error"`
+}
+
+func JSON(c *gin.Context, status int, payload any) {
+ c.JSON(status, payload)
+}
+
+func JSONError(c *gin.Context, status int, message string) {
+ c.AbortWithStatusJSON(status, ErrorResponse{Error: message})
+}
diff --git a/internal/utils/response_test.go b/internal/utils/response_test.go
new file mode 100644
index 0000000..626c065
--- /dev/null
+++ b/internal/utils/response_test.go
@@ -0,0 +1,34 @@
+package utils
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestJSONHelpers(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ t.Run("json", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ JSON(c, http.StatusCreated, gin.H{"status": "ok"})
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("unexpected code: %d", rec.Code)
+ }
+ if rec.Body.String() == "" {
+ t.Fatal("expected body")
+ }
+ })
+
+ t.Run("json error", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ JSONError(c, http.StatusBadRequest, "bad request")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("unexpected code: %d", rec.Code)
+ }
+ })
+}
diff --git a/internal/utils/session.go b/internal/utils/session.go
new file mode 100644
index 0000000..c5ba53a
--- /dev/null
+++ b/internal/utils/session.go
@@ -0,0 +1,94 @@
+package utils
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+)
+
+type SessionClaims struct {
+ AdminID int64 `json:"admin_id"`
+ ExpiresAt int64 `json:"expires_at"`
+}
+
+func SignSession(secret []byte, adminID int64, ttl time.Duration) (string, error) {
+ claims := SessionClaims{
+ AdminID: adminID,
+ ExpiresAt: time.Now().Add(ttl).UTC().Unix(),
+ }
+ payload, err := json.Marshal(claims)
+ if err != nil {
+ return "", err
+ }
+ encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
+ mac := hmac.New(sha256.New, secret)
+ _, _ = mac.Write([]byte(encodedPayload))
+ signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
+ return encodedPayload + "." + signature, nil
+}
+
+func VerifySession(secret []byte, token string) (SessionClaims, error) {
+ parts := splitToken(token)
+ if len(parts) != 2 {
+ return SessionClaims{}, errors.New("invalid session token")
+ }
+
+ expectedMAC := hmac.New(sha256.New, secret)
+ _, _ = expectedMAC.Write([]byte(parts[0]))
+ expectedSignature := expectedMAC.Sum(nil)
+
+ signature, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return SessionClaims{}, errors.New("invalid session signature")
+ }
+ if !hmac.Equal(signature, expectedSignature) {
+ return SessionClaims{}, errors.New("invalid session signature")
+ }
+
+ payload, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil {
+ return SessionClaims{}, errors.New("invalid session payload")
+ }
+
+ var claims SessionClaims
+ if err := json.Unmarshal(payload, &claims); err != nil {
+ return SessionClaims{}, err
+ }
+ if time.Now().UTC().Unix() > claims.ExpiresAt {
+ return SessionClaims{}, errors.New("session expired")
+ }
+ return claims, nil
+}
+
+func GenerateSecretKey(size int) ([]byte, error) {
+ if size < 32 {
+ size = 32
+ }
+ key := make([]byte, size)
+ if _, err := rand.Read(key); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+func splitToken(token string) []string {
+ var parts []string
+ start := 0
+ for i := 0; i < len(token); i++ {
+ if token[i] == '.' {
+ parts = append(parts, token[start:i])
+ start = i + 1
+ }
+ }
+ parts = append(parts, token[start:])
+ return parts
+}
+
+func FormatSessionKey(secret []byte) string {
+ return fmt.Sprintf("%x", secret)
+}
diff --git a/internal/utils/session_test.go b/internal/utils/session_test.go
new file mode 100644
index 0000000..f900395
--- /dev/null
+++ b/internal/utils/session_test.go
@@ -0,0 +1,46 @@
+package utils
+
+import (
+ "testing"
+ "time"
+)
+
+func TestSessionHelpers(t *testing.T) {
+ key, err := GenerateSecretKey(16)
+ if err != nil {
+ t.Fatalf("generate secret key: %v", err)
+ }
+ if len(key) != 32 {
+ t.Fatalf("expected minimum 32-byte key, got %d", len(key))
+ }
+
+ if got := FormatSessionKey([]byte{0x01, 0x02, 0x0a}); got != "01020a" {
+ t.Fatalf("unexpected formatted key: %s", got)
+ }
+
+ secret := []byte("01234567890123456789012345678901")
+ token, err := SignSession(secret, 42, time.Minute)
+ if err != nil {
+ t.Fatalf("sign session: %v", err)
+ }
+
+ claims, err := VerifySession(secret, token)
+ if err != nil {
+ t.Fatalf("verify session: %v", err)
+ }
+ if claims.AdminID != 42 {
+ t.Fatalf("unexpected admin id: %d", claims.AdminID)
+ }
+
+ if _, err := VerifySession(secret, token+"tamper"); err == nil {
+ t.Fatal("expected tampered token to fail")
+ }
+
+ expired, err := SignSession(secret, 7, -time.Minute)
+ if err != nil {
+ t.Fatalf("sign expired session: %v", err)
+ }
+ if _, err := VerifySession(secret, expired); err == nil {
+ t.Fatal("expected expired session to fail")
+ }
+}
diff --git a/internal/utils/validation.go b/internal/utils/validation.go
new file mode 100644
index 0000000..e35ada9
--- /dev/null
+++ b/internal/utils/validation.go
@@ -0,0 +1,35 @@
+package utils
+
+import (
+ "errors"
+ "fmt"
+ "net/mail"
+ "strings"
+)
+
+const (
+ AdminNameMinLength = 3
+ AdminNameMaxLength = 50
+)
+
+func ValidateAdminName(name string) error {
+ trimmed := strings.TrimSpace(name)
+ if len(trimmed) < AdminNameMinLength {
+ return fmt.Errorf("admin name must be at least %d characters", AdminNameMinLength)
+ }
+ if len(trimmed) > AdminNameMaxLength {
+ return fmt.Errorf("admin name must be at most %d characters", AdminNameMaxLength)
+ }
+ return nil
+}
+
+func ValidateEmail(email string) error {
+ trimmed := strings.TrimSpace(email)
+ if trimmed == "" {
+ return errors.New("email is required")
+ }
+ if _, err := mail.ParseAddress(trimmed); err != nil {
+ return errors.New("email format is invalid")
+ }
+ return nil
+}
diff --git a/internal/utils/validation_test.go b/internal/utils/validation_test.go
new file mode 100644
index 0000000..0dc7f26
--- /dev/null
+++ b/internal/utils/validation_test.go
@@ -0,0 +1,27 @@
+package utils
+
+import "testing"
+
+func TestValidateAdminName(t *testing.T) {
+ if err := ValidateAdminName("ab"); err == nil {
+ t.Fatal("expected short name to fail")
+ }
+ if err := ValidateAdminName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"); err == nil {
+ t.Fatal("expected long name to fail")
+ }
+ if err := ValidateAdminName("Admin User"); err != nil {
+ t.Fatalf("expected valid name, got %v", err)
+ }
+}
+
+func TestValidateEmail(t *testing.T) {
+ if err := ValidateEmail(""); err == nil {
+ t.Fatal("expected empty email to fail")
+ }
+ if err := ValidateEmail("not-an-email"); err == nil {
+ t.Fatal("expected invalid email to fail")
+ }
+ if err := ValidateEmail("admin@example.com"); err != nil {
+ t.Fatalf("expected valid email, got %v", err)
+ }
+}
diff --git a/internal/utils/version.go b/internal/utils/version.go
new file mode 100644
index 0000000..8b27b63
--- /dev/null
+++ b/internal/utils/version.go
@@ -0,0 +1,72 @@
+package utils
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+)
+
+var versionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
+
+type SemanticVersion struct {
+ Major int
+ Minor int
+ Patch int
+}
+
+func ParseSemanticVersion(value string) (SemanticVersion, error) {
+ if !versionPattern.MatchString(value) {
+ return SemanticVersion{}, errors.New("version must use format major.minor.patch")
+ }
+
+ parts := make([]int, 0, 3)
+ start := 0
+ for i := 0; i <= len(value); i++ {
+ if i == len(value) || value[i] == '.' {
+ part, err := strconv.Atoi(value[start:i])
+ if err != nil {
+ return SemanticVersion{}, fmt.Errorf("invalid version number: %w", err)
+ }
+ parts = append(parts, part)
+ start = i + 1
+ }
+ }
+
+ return SemanticVersion{
+ Major: parts[0],
+ Minor: parts[1],
+ Patch: parts[2],
+ }, nil
+}
+
+func CompareSemanticVersion(a, b string) (int, error) {
+ left, err := ParseSemanticVersion(a)
+ if err != nil {
+ return 0, err
+ }
+ right, err := ParseSemanticVersion(b)
+ if err != nil {
+ return 0, err
+ }
+
+ switch {
+ case left.Major != right.Major:
+ if left.Major < right.Major {
+ return -1, nil
+ }
+ return 1, nil
+ case left.Minor != right.Minor:
+ if left.Minor < right.Minor {
+ return -1, nil
+ }
+ return 1, nil
+ case left.Patch != right.Patch:
+ if left.Patch < right.Patch {
+ return -1, nil
+ }
+ return 1, nil
+ default:
+ return 0, nil
+ }
+}
diff --git a/internal/utils/version_test.go b/internal/utils/version_test.go
new file mode 100644
index 0000000..9627d4d
--- /dev/null
+++ b/internal/utils/version_test.go
@@ -0,0 +1,39 @@
+package utils
+
+import "testing"
+
+func TestCompareSemanticVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ a string
+ b string
+ want int
+ }{
+ {name: "equal", a: "1.0.0", b: "1.0.0", want: 0},
+ {name: "greater major", a: "2.0.0", b: "1.9.9", want: 1},
+ {name: "greater minor", a: "1.2.0", b: "1.1.9", want: 1},
+ {name: "greater patch", a: "1.2.3", b: "1.2.2", want: 1},
+ {name: "less", a: "1.2.2", b: "1.2.3", want: -1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := CompareSemanticVersion(tt.a, tt.b)
+ if err != nil {
+ t.Fatalf("compare version: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("compare version = %d, want %d", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseSemanticVersion(t *testing.T) {
+ if _, err := ParseSemanticVersion("1.2"); err == nil {
+ t.Fatal("expected invalid version format")
+ }
+ if _, err := ParseSemanticVersion("1.2.3"); err != nil {
+ t.Fatalf("parse semantic version: %v", err)
+ }
+}
diff --git a/storage/.gitkeep b/storage/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tmp/build-errors.log b/tmp/build-errors.log
new file mode 100644
index 0000000..c38c619
--- /dev/null
+++ b/tmp/build-errors.log
@@ -0,0 +1 @@
+exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
\ No newline at end of file
diff --git a/tmp/main b/tmp/main
new file mode 100755
index 0000000..10c6ede
Binary files /dev/null and b/tmp/main differ
diff --git a/web/postman.go b/web/postman.go
new file mode 100644
index 0000000..6e24ee2
--- /dev/null
+++ b/web/postman.go
@@ -0,0 +1,8 @@
+package web
+
+import "embed"
+
+// PostmanFS embeds sample API collections into the binary.
+//
+//go:embed postman/*.json
+var PostmanFS embed.FS
diff --git a/web/postman/GoAppMon.postman_collection.json b/web/postman/GoAppMon.postman_collection.json
new file mode 100644
index 0000000..8c2c807
--- /dev/null
+++ b/web/postman/GoAppMon.postman_collection.json
@@ -0,0 +1,145 @@
+{
+ "info": {
+ "name": "GoAppMon API",
+ "description": "Sample Postman collection for GoAppMon public and admin API workflows.",
+ "_postman_id": "c4c1f6f0-8a68-4d8f-a5f6-6d4e9f1c9a01",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "variable": [
+ {
+ "key": "base_url",
+ "value": "http://localhost:18180"
+ },
+ {
+ "key": "admin_email",
+ "value": "admin@example.com"
+ },
+ {
+ "key": "admin_password",
+ "value": "secret123"
+ }
+ ],
+ "item": [
+ {
+ "name": "Health",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/health",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "health"
+ ]
+ }
+ }
+ },
+ {
+ "name": "Status",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/api/status",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "api",
+ "status"
+ ]
+ }
+ }
+ },
+ {
+ "name": "Version",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/api/version",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "api",
+ "version"
+ ]
+ }
+ }
+ },
+ {
+ "name": "Config",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/api/config",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "api",
+ "config"
+ ]
+ }
+ }
+ },
+ {
+ "name": "Feature Flags",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/api/feature-flags",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "api",
+ "feature-flags"
+ ]
+ }
+ }
+ },
+ {
+ "name": "Admin Login",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/x-www-form-urlencoded"
+ }
+ ],
+ "body": {
+ "mode": "urlencoded",
+ "urlencoded": [
+ {
+ "key": "email",
+ "value": "{{admin_email}}",
+ "type": "text"
+ },
+ {
+ "key": "password",
+ "value": "{{admin_password}}",
+ "type": "text"
+ }
+ ]
+ },
+ "url": {
+ "raw": "{{base_url}}/admin/login",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "admin",
+ "login"
+ ]
+ }
+ }
+ }
+ ]
+}
diff --git a/web/templates.go b/web/templates.go
new file mode 100644
index 0000000..794a4d7
--- /dev/null
+++ b/web/templates.go
@@ -0,0 +1,8 @@
+package web
+
+import "embed"
+
+// TemplatesFS embeds the admin UI templates into the binary.
+//
+//go:embed templates/**/*.html
+var TemplatesFS embed.FS
diff --git a/web/templates/components/audit_logs_table.html b/web/templates/components/audit_logs_table.html
new file mode 100644
index 0000000..7809de9
--- /dev/null
+++ b/web/templates/components/audit_logs_table.html
@@ -0,0 +1,66 @@
+{{ define "audit-logs-table" }}
+
+
+
+
+
+
+ | Action |
+ Entity |
+ Actor |
+ Time |
+ Details |
+
+
+
+ {{ range .Logs }}
+
+ | {{ .Action }} |
+ {{ .EntityType }} {{ if .EntityID }}#{{ .EntityID }}{{ end }} |
+
+ {{ if .ActorName }}{{ .ActorName }}{{ else if .ActorAdminID }}Admin #{{ .ActorAdminID }}{{ else }}System{{ end }}
+ {{ .IP }}
+ |
+ {{ humanTime .CreatedAt }} |
+
+
+ View payload
+
+
+ Before
+ {{ if .BeforeJSON }}{{ .BeforeJSON }}{{ else }}-{{ end }}
+
+
+ After
+ {{ if .AfterJSON }}{{ .AfterJSON }}{{ else }}-{{ end }}
+
+ User agent: {{ .UserAgent }}
+
+
+ |
+
+ {{ else }}
+
+ | No audit logs match the current filters. |
+
+ {{ end }}
+
+
+
+
+
+
+
+ Page {{ .Page }} of {{ .TotalPages }}, showing {{ .PageSize }} per page.
+
+
+ {{ if .HasPrev }}
+
Previous
+ {{ end }}
+ {{ if .HasNext }}
+
Next
+ {{ end }}
+
+
+
+{{ end }}
diff --git a/web/templates/components/flash.html b/web/templates/components/flash.html
new file mode 100644
index 0000000..9216810
--- /dev/null
+++ b/web/templates/components/flash.html
@@ -0,0 +1,14 @@
+{{ define "flash" }}
+
+ {{ if .Notice }}
+
+ {{ .Notice }}
+
+ {{ end }}
+ {{ if .Error }}
+
+ {{ .Error }}
+
+ {{ end }}
+
+{{ end }}
diff --git a/web/templates/components/system_health_panel.html b/web/templates/components/system_health_panel.html
new file mode 100644
index 0000000..9a0bbcc
--- /dev/null
+++ b/web/templates/components/system_health_panel.html
@@ -0,0 +1,174 @@
+{{ define "system-health-panel" }}
+
+
+
+
+
+ System health
+ Last refreshed {{ humanTime .SystemHealth.UpdatedAt }}
+
+
Server and runtime overview
+
Track host CPU, memory, disk pressure, app runtime stats, and SQLite storage in one dashboard.
+
+
+
+
+
+ Health score
+
+
+
+
{{ .SystemHealth.Score }}
+
{{ .SystemHealth.Status }}
+
+
+
+
+
+
+
+
+
+
{{ printf "%.1f" .SystemHealth.CPUUsagePercent }}%
+
{{ .SystemHealth.NumCPU }} cores · system-wide load
+
+
+
+
+
+
+
+
+
+
{{ printf "%.1f" .SystemHealth.MemoryUsedPercent }}%
+
{{ printf "%.1f" .SystemHealth.MemoryUsedMB }} MB used of {{ printf "%.1f" .SystemHealth.MemoryTotalMB }} MB
+
+
Host
+
+
+
Free: {{ printf "%.1f" .SystemHealth.MemoryFreeMB }} MB
+
+
+
+
+
+
+
{{ printf "%.1f" .SystemHealth.DiskUsedPercent }}%
+
{{ printf "%.1f" .SystemHealth.DiskUsedGB }} GB used of {{ printf "%.1f" .SystemHealth.DiskTotalGB }} GB
+
+
Storage
+
+
+
{{ .SystemHealth.SQLiteFilePath }}
+
+
+
+
+
+
+
Process uptime
+
{{ .SystemHealth.Uptime }}
+
How long GoAppMon has been running
+
+
+
+
System uptime
+
{{ if .SystemHealth.SystemUptime }}{{ .SystemHealth.SystemUptime }}{{ else }}unavailable{{ end }}
+
How long the host machine has been up
+
+
+
+
App memory
+
{{ printf "%.1f" .SystemHealth.AllocMB }} MB
+
Runtime sys: {{ printf "%.1f" .SystemHealth.SysMB }} MB
+
+
+
+
Goroutines
+
{{ .SystemHealth.NumGoroutine }}
+
Go {{ .SystemHealth.GoVersion }}
+
+
+
+
+
+
SQLite storage
+
{{ .SystemHealth.SQLiteFileSize }}
+
{{ .SystemHealth.SQLiteFilePath }}
+
+
+
Health style
+
+ {{ .SystemHealth.Status }}
+
+
Score reflects host pressure across CPU, memory, disk, and runtime signals.
+
+
+
+
Last refresh
+
Manual
+
+
{{ humanTime .SystemHealth.UpdatedAt }}
+
Click refresh to fetch the latest machine snapshot.
+
+
+
+ {{ if .SystemHealth.HealthNotes }}
+
+
Signals
+
+ {{ range .SystemHealth.HealthNotes }}
+ {{ . }}
+ {{ end }}
+
+
+ {{ end }}
+
+
+{{ end }}
diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html
new file mode 100644
index 0000000..053250b
--- /dev/null
+++ b/web/templates/layouts/base.html
@@ -0,0 +1,131 @@
+{{ define "base" }}
+
+
+
+
+
+ {{ if .Title }}{{ .Title }} · {{ appName }}{{ else }}{{ appName }}{{ end }}
+
+
+
+
+
+
+
+
+
+ {{ template "flash" . }}
+
+
+ {{ template "content" . }}
+
+
+
+
+
+
+
About
+
GoAppMon
+
+ GoAppMon is a small admin app for version history, maintenance messages, banners, feature flags, and audit logs.
+
+
+ Built and maintained by Phyo Wai Yan.
+
+
+
+
+
+
+
+
+
+
+{{ end }}
diff --git a/web/templates/pages/audit_logs.html b/web/templates/pages/audit_logs.html
new file mode 100644
index 0000000..f426800
--- /dev/null
+++ b/web/templates/pages/audit_logs.html
@@ -0,0 +1,45 @@
+{{ define "content" }}
+
+
+
+
Back
+
+
Logs
+
Audit log
+
+ Search recent admin actions. Keep it simple and quick.
+
+
+
+
+
Total logs
+
{{ .Total }}
+
+
+
+
+
+ {{ template "audit-logs-table" . }}
+
+{{ end }}
diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html
new file mode 100644
index 0000000..e5cd983
--- /dev/null
+++ b/web/templates/pages/dashboard.html
@@ -0,0 +1,522 @@
+{{ define "content" }}
+
+
+
+
+
Application
+ {{ if .CurrentAdmin }}
+
Signed in as {{ .CurrentAdmin.Name }}
+ {{ end }}
+
+
+
+
+
+
+
+
+
+
Releases
+
Android and iOS versions
+
Keep version history and platform toggles in one place.
+
+
+
+
+
+
+
+
+
Android
+
Current version
+
+
+ {{ if .Settings.AndroidEnabled }}
+ Enabled
+ {{ else }}
+ Disabled
+ {{ end }}
+ {{ if gt (len .AndroidReleases) 1 }}
+
+ {{ end }}
+
+
+
+
+
Latest
+
{{ .Settings.AndroidLatestVersion }}
+
Most recent Android release
+
+
+
Minimum
+
{{ .Settings.AndroidMinVersion }}
+
Lowest supported Android release
+
+
+
Force update
+ {{ if .Settings.AndroidForceUpdate }}
+
Enabled
+ {{ else }}
+
Disabled
+ {{ end }}
+
Users must update before continuing
+
+
+
+ {{ if .Settings.AndroidEnabled }}
+
+ {{ else }}
+
+ Android is disabled. Turn it on above to add releases.
+
+ {{ end }}
+
+
+
+
+
+ | Version |
+ Minimum |
+ Status |
+ Added |
+
+
+
+ {{ range .AndroidReleases }}
+
+ | {{ .LatestVersion }} |
+ {{ .MinimumVersion }} |
+
+ {{ if .ForceUpdate }}
+ Force update
+ {{ else }}
+ Optional
+ {{ end }}
+ |
+ {{ humanTime .CreatedAt }} |
+
+ {{ else }}
+ | No Android release history yet. |
+ {{ end }}
+
+
+
+
+
+
+
+
+
iOS
+
Current version
+
+
+ {{ if .Settings.IOSEnabled }}
+ Enabled
+ {{ else }}
+ Disabled
+ {{ end }}
+ {{ if gt (len .IOSReleases) 1 }}
+
+ {{ end }}
+
+
+
+
+
Latest
+
{{ .Settings.IOSLatestVersion }}
+
Most recent iOS release
+
+
+
Minimum
+
{{ .Settings.IOSMinVersion }}
+
Lowest supported iOS release
+
+
+
Force update
+ {{ if .Settings.IOSForceUpdate }}
+
Enabled
+ {{ else }}
+
Disabled
+ {{ end }}
+
Users must update before continuing
+
+
+
+ {{ if .Settings.IOSEnabled }}
+
+ {{ else }}
+
+ iOS is disabled. Turn it on above to add releases.
+
+ {{ end }}
+
+
+
+
+
+ | Version |
+ Minimum |
+ Status |
+ Added |
+
+
+
+ {{ range .IOSReleases }}
+
+ | {{ .LatestVersion }} |
+ {{ .MinimumVersion }} |
+
+ {{ if .ForceUpdate }}
+ Force update
+ {{ else }}
+ Optional
+ {{ end }}
+ |
+ {{ humanTime .CreatedAt }} |
+
+ {{ else }}
+ | No iOS release history yet. |
+ {{ end }}
+
+
+
+
+
+
+
+
+
+
+
Maintenance
+
+
+
+
+
+
+ | State |
+ Message |
+ Added |
+
+
+
+ {{ range .MaintenanceHistory }}
+
+ |
+ {{ if .Enabled }}
+ Enabled
+ {{ else }}
+ Disabled
+ {{ end }}
+ |
+ {{ if .Message }}{{ .Message }}{{ else }}—{{ end }} |
+ {{ humanTime .CreatedAt }} |
+
+ {{ else }}
+ | No maintenance history yet. |
+ {{ end }}
+
+
+
+
+
+
+
+
+
Banner
+
+
+
+
+
+
+ | State |
+ Message |
+ Added |
+
+
+
+ {{ range .BannerHistory }}
+
+ |
+ {{ if .Enabled }}
+ Enabled
+ {{ else }}
+ Disabled
+ {{ end }}
+ |
+ {{ if .Message }}{{ .Message }}{{ else }}—{{ end }} |
+ {{ humanTime .CreatedAt }} |
+
+ {{ else }}
+ | No banner history yet. |
+ {{ end }}
+
+
+
+
+
+
+
+
+
+
+
Feature flags
+
Create and manage feature switches for clients.
+
+
+
+
+
+
+
+
+
+ {{ template "system-health-panel" . }}
+
+
+
+
+
+
Audit log
+
Recent system changes with actor, entity, and request data.
+
+
See All logs
+
+
+
+
+
+
+ | Action |
+ Entity |
+ Actor |
+ Time |
+
+
+
+ {{ range .AuditLogs }}
+
+ | {{ .Action }} |
+ {{ .EntityType }} {{ if .EntityID }}#{{ .EntityID }}{{ end }} |
+
+ {{ if .ActorAdminID }}Admin #{{ .ActorAdminID }}{{ else }}System{{ end }}
+ {{ .IP }}
+ |
+ {{ humanTime .CreatedAt }} |
+
+ {{ else }}
+ | No audit log entries yet. |
+ {{ end }}
+
+
+
+
+
+
+
+{{ end }}
diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html
new file mode 100644
index 0000000..138964a
--- /dev/null
+++ b/web/templates/pages/login.html
@@ -0,0 +1,53 @@
+{{ define "content" }}
+
+
+
+
+
Admin login
+
Sign in
+
Use your admin email and password.
+
+
+
+
+
+
+
+{{ end }}
diff --git a/web/templates/pages/setup.html b/web/templates/pages/setup.html
new file mode 100644
index 0000000..debfc42
--- /dev/null
+++ b/web/templates/pages/setup.html
@@ -0,0 +1,67 @@
+{{ define "content" }}
+
+
+
+
+
First-time setup
+
Create admin account
+
This creates the first admin user and the initial app settings.
+
+
+
+
+
+
+
+{{ end }}