first commit

This commit is contained in:
Maxime Duchêne-Savard 2026-08-07 14:45:27 -04:00
commit e75ff83f19
43 changed files with 10284 additions and 0 deletions

10
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/faro.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/faro.iml" filepath="$PROJECT_DIR$/.idea/faro.iml" />
</modules>
</component>
</project>

372
PLAN.md Normal file
View File

@ -0,0 +1,372 @@
# Faro: Docker Engine Management UI
> Living project plan. Add ideas to the backlog, record architectural choices in
> the decision log, and promote accepted work into a milestone.
## 1. Product vision
Faro is a small, self-hosted control plane for managing one or more Docker
Engines. It provides a clear web UI, an automation-friendly API, and CLI tools,
with special attention to easy deployment, reliable backups, and operational
visibility.
The initial product should be useful to an individual operator or a small team.
It is not intended to replace a full container orchestrator such as Kubernetes.
## 2. Product principles
- **Easy to deploy:** a single server binary and container image, with sensible
defaults and a documented Docker Compose installation.
- **API first:** every management operation available in the UI is exposed by a
stable, documented API and can be automated.
- **Safe by default:** destructive actions require explicit confirmation;
credentials and Docker access are narrowly scoped.
- **Recoverable:** configuration, metadata, and named volumes can be backed up,
verified, and restored through repeatable workflows.
- **Visible:** users can quickly understand engine health, resource usage,
container state, recent events, and job outcomes.
- **Small operational footprint:** avoid required external services for the
single-node installation.
## 3. Users and core journeys
### Primary users
- A self-hosting user managing one or more Docker hosts.
- A small operations team that needs shared visibility and controlled access.
- An automation author using the REST API or CLI.
### Core journeys
1. Deploy Faro with Docker Compose and complete first-run setup.
2. Register a local or remote Docker Engine and verify connectivity.
3. View engine, container, image, network, and volume status.
4. Start, stop, restart, inspect, and view logs for a container.
5. Create and restore a backup of Faro state and selected Docker volumes.
6. Diagnose failures through events, job history, logs, and health information.
7. Perform the same common operations using the CLI or API.
## 4. Initial scope
### MVP
- Single Faro server instance.
- Local Docker socket and remote Docker Engine connections over TLS/SSH.
- Engine list and health summary.
- Read-only inventory for containers, images, volumes, and networks.
- Container lifecycle operations and streaming logs.
- Engine event stream and basic CPU, memory, disk, and container metrics.
- Scheduled and on-demand backups of Faro state and selected named volumes.
- Local filesystem backup target, with a provider interface for object storage.
- Backup retention, integrity verification, and guided restore.
- REST API with an OpenAPI specification.
- CLI for engines, containers, backups, health, and authentication.
- Web UI built with Vue 3 and Vuetify0.
- Web UI page for listing containers from the Docker Engine API.
- Initial administrator account, API tokens, and a basic audit log.
- Docker image, Compose file, health check, and upgrade documentation.
### Later
- Multi-user roles and granular permissions.
- S3-compatible and other remote backup targets.
- Compose application/stack management.
- Image updates, registry management, and vulnerability information.
- Notifications and alert routing.
- High availability and multiple Faro server replicas.
- Agent-based engine connectivity for restricted networks.
- Swarm-specific management.
### Explicitly out of scope for the first release
- Kubernetes or non-Docker runtimes.
- A general-purpose terminal in the browser.
- Full orchestration or scheduling across engines.
- Building a container registry.
## 5. Proposed architecture
```text
Browser (Vue + Vuetify) CLI
| |
+------ HTTPS API ----+
|
Faro server (Go)
+-----------+------------+
| | |
SQLite Job runner Event/metric cache
|
Docker client layer
+---------+---------+
| |
Local socket Remote engines
(TLS or SSH)
```
### Backend
- Go service organized as a modular monolith.
- Versioned REST endpoints under `/api/v1`.
- OpenAPI is the API contract and drives documentation/client generation where
practical.
- Docker Engine SDK behind an internal interface so connectivity and tests can
use alternate implementations.
- SQLite for the default installation; keep persistence boundaries clean enough
to support PostgreSQL later if demand justifies it.
- Persistent job records for backups and other long-running operations.
- Server-Sent Events (SSE) for logs, events, job progress, and live status;
introduce WebSockets only if bidirectional streaming becomes necessary.
- Structured JSON logs and Prometheus-format application metrics.
- Embed the production frontend in the Go binary for a simple single-artifact
deployment; allow separate frontend/backend processes in development.
### Frontend
- Vue 3, TypeScript, Vite, Vuetify 3, Vue Router, and Pinia.
- Generated or typed API client based on OpenAPI.
- Primary views: overview, engines, engine detail, containers, container detail,
storage, backups, jobs, audit log, and settings.
- Responsive layout, keyboard-accessible actions, clear empty/error/loading
states, and a dark theme.
### CLI
- Go CLI shipped as a separate `faroctl` binary.
- Reuse generated API types/client rather than connecting directly to Docker.
- Human-readable tables by default; `--output json` for automation.
- Configuration profiles for server URL and credentials.
- Stable exit codes, non-interactive flags, and shell completions.
## 6. Key domain areas
### Engine connections
- Store engine name, endpoint type, labels, connection state, and last check.
- Support local Unix socket, TLS-protected TCP, and SSH connection strategies.
- Encrypt stored credentials at rest with a user-supplied master key.
- Never expose Docker credentials or raw private keys through API responses.
- Use timeouts, reconnect backoff, and explicit capability detection.
### Backups and restores
- Treat backups as durable jobs with progress, logs, status, and cancellation.
- Back up Faro's database/configuration separately from Docker volume data.
- Quiesce supported workloads with optional pre/post hooks; clearly label crash-
consistent backups when a workload is not paused.
- Stream volume archives without staging the full archive in memory.
- Produce a manifest containing versions, contents, timestamps, checksums, and
source engine identity.
- Verify checksums after creation and before restore.
- Apply retention by count and/or age, with a dry-run preview.
- Restore to an alternate volume name by default; overwriting an existing volume
requires explicit confirmation.
- Document recovery when the Faro service itself is unavailable.
### Visibility
- Engine availability and Docker version.
- Container state, health check, restart count, uptime, and resource usage.
- Host CPU, memory, filesystem, and Docker storage usage.
- Recent Docker events and Faro audit events.
- Backup/job duration, result, bytes processed, and last successful run.
- Correlation/request IDs across API errors, jobs, and structured logs.
### Security
- Document that access to the Docker socket is effectively host-level control.
- Run the server as a non-root user where the connection method permits it.
- Password hashing with a modern memory-hard algorithm; short-lived sessions and
revocable API tokens.
- CSRF protection for cookie-authenticated browser requests, strict CORS, secure
headers, request size limits, and rate limiting on authentication endpoints.
- Audit authentication, engine changes, lifecycle actions, backup restores, and
token changes without logging secrets.
- Pin minimal container base images and publish an SBOM and checksums for
releases.
## 7. API outline
The exact resources will be defined in OpenAPI before implementation. Candidate
resource groups:
- `/api/v1/session`, `/api/v1/tokens`
- `/api/v1/engines`
- `/api/v1/engines/{engineId}/containers`
- `/api/v1/engines/{engineId}/images`
- `/api/v1/engines/{engineId}/volumes`
- `/api/v1/engines/{engineId}/networks`
- `/api/v1/backup-targets`, `/api/v1/backup-policies`, `/api/v1/backups`
- `/api/v1/jobs`, `/api/v1/events`, `/api/v1/audit-events`
- `/api/v1/health`, `/api/v1/version`, `/metrics`
API conventions to decide early:
- Resource IDs, pagination, filtering, sorting, and timestamp format.
- Standard error envelope with a stable machine-readable error code.
- Idempotency behavior for mutating and long-running requests.
- Optimistic concurrency or preconditions for configuration changes.
- SSE event envelope, resume behavior, and connection limits.
- Compatibility and deprecation policy for `/api/v1`.
## 8. Delivery milestones
Each milestone should end with a runnable increment, documentation, and tests.
### M0 — Validate and scaffold
- [ ] Confirm target users and the exact MVP scope.
- [ ] Resolve the open decisions listed below.
- [ ] Write threat model for Docker access, credentials, and restores.
- [ ] Create repository layout for server, CLI, web app, API spec, and docs.
- [ ] Establish formatting, linting, unit tests, and CI.
- [ ] Add a development Compose environment and sample Docker Engine.
- [ ] Add a minimal end-to-end smoke test.
**Exit:** one command starts the development stack; CI builds and tests all
components.
### M1 — Engine connectivity and read-only inventory
- [ ] Implement configuration, database migrations, health, and version APIs.
- [ ] Add local socket engine registration and connectivity checks.
- [ ] Add remote TLS and/or SSH connectivity based on the M0 decision.
- [ ] Implement read-only container, image, volume, and network APIs.
- [ ] Build overview, engine list, and engine detail UI.
- [ ] Add equivalent `faroctl engine` and inventory commands.
- [ ] Add integration tests against supported Docker versions.
**Exit:** a user can register an engine and inspect its resources through the
web UI, CLI, and API.
### M2 — Container operations and live visibility
- [ ] Add start, stop, restart, and inspect operations with audit records.
- [ ] Add log streaming with bounded history and redaction guidance.
- [ ] Consume Docker events and expose an SSE stream.
- [ ] Collect and display basic engine/container resource metrics.
- [ ] Add operation confirmation, actionable errors, and reconnect states.
- [ ] Add permission and failure-path tests.
**Exit:** a user can safely operate and troubleshoot containers in near real
time.
### M3 — Backup and restore
- [ ] Implement persistent background jobs and progress streaming.
- [ ] Implement local filesystem target and backup manifests.
- [ ] Add named-volume backup, checksums, retention, and verification.
- [ ] Add scheduled backup policies with timezone handling.
- [ ] Add guided restore, collision protection, and restore validation.
- [ ] Back up and restore Faro's own state.
- [ ] Run documented disaster-recovery tests on clean infrastructure.
**Exit:** scheduled backups and tested restores work from the UI, CLI, and API.
### M4 — Authentication, hardening, and release
- [ ] Implement first-run admin setup, sessions, and API tokens.
- [ ] Encrypt stored engine credentials and define key rotation/recovery.
- [ ] Complete audit log UI/API and security controls.
- [ ] Add retention/cleanup for events, metrics, jobs, and audit data.
- [ ] Load-test event streams, logs, and representative engine counts.
- [ ] Build a minimal production image, Compose example, and upgrade flow.
- [ ] Publish operator, backup recovery, API, and CLI documentation.
- [ ] Add release automation, SBOM, checksums, and signed artifacts.
**Exit:** the first supported release can be installed, upgraded, monitored, and
recovered using published documentation.
## 9. Testing strategy
- Go unit tests for domain logic, validation, retention, and authorization.
- API contract tests against the OpenAPI schema.
- Integration tests using disposable Docker Engines and temporary volumes.
- Frontend component tests for important state and permission variants.
- Playwright end-to-end tests for the web UI, including setup, engine
registration, container operations, backup, and restore.
- Restore tests must compare checksums and application-level sample data.
- Compatibility matrix for supported Docker Engine and browser versions.
- Security checks for dependencies, container images, secrets, and common web
vulnerabilities.
## 10. Deployment and operations
Start with three supported modes:
1. Docker Compose with Faro connecting to the local Docker socket.
2. Docker Compose with Faro managing remote engines over TLS/SSH.
3. Standalone binaries for the server and CLI.
Required operational features:
- Environment variables and a configuration file, with documented precedence.
- Persistent data directory and an explicit master-key mechanism.
- Liveness and readiness endpoints.
- Graceful shutdown for HTTP streams and active jobs.
- Schema migration and downgrade/rollback guidance.
- Configurable structured logs and Prometheus metrics.
- Versioned release notes with breaking-change and backup warnings.
## 11. Open decisions
Record the result and rationale in the decision log.
- [ ] **D-001:** Confirm Vue 3 + Vuetify 3 (assuming “Vuetify0” was a typo).
- [ ] **D-002:** Choose the Go HTTP router and OpenAPI generation approach.
- [ ] **D-003:** Choose database access/migration libraries.
- [ ] **D-004:** Decide whether the MVP includes both TLS and SSH remote engines.
- [ ] **D-005:** Define the authentication bootstrap and master-key experience.
- [ ] **D-006:** Decide whether backups run directly through the Docker API or via
a short-lived helper container.
- [ ] **D-007:** Define supported Docker Engine versions and maximum tested scale.
- [ ] **D-008:** Define license and release/distribution channels.
- [ ] **D-009:** Choose a metrics retention model: live-only, local time series,
or integration with an external metrics system.
## 12. Success measures
Initial targets; adjust after a prototype and user feedback.
- A new user can deploy Faro and connect the local engine in under 10 minutes.
- Common UI operations are also possible through documented CLI/API commands.
- Engine disconnects and failed jobs are visible with an actionable reason.
- A backup can be verified and restored on a clean host using only documented
steps.
- The idle server has a small, measured CPU and memory footprint.
- Upgrades preserve configuration and include an explicit recovery path.
## 13. Risks and mitigations
| Risk | Mitigation |
| --- | --- |
| Docker access permits host compromise | Strong warnings, narrow connection options, authentication, audit, and deployment hardening |
| Volume backups are inconsistent | Pre/post hooks, optional pause, manifests, verification, and clearly stated consistency level |
| Remote connection setup is difficult | Connection wizard, validation endpoint, actionable diagnostics, TLS/SSH examples |
| Metrics storage increases product complexity | Start with bounded retention and an export endpoint; defer a full time-series system |
| UI, CLI, and API behavior diverge | API-first implementation and a shared generated client |
| Restore destroys existing data | Restore to a new name by default, preview changes, require explicit overwrite confirmation |
## 14. Decision log
Add one row whenever an open decision is resolved.
| ID | Date | Decision | Rationale | Status |
| --- | --- | --- | --- | --- |
| D-001 | TBD | Vue/Vuetify version | Awaiting confirmation | Proposed |
## 15. Backlog and idea inbox
Add unrefined ideas here without disrupting the milestones. Give each accepted
item an owner and promote it to a milestone when it is ready.
| ID | Idea | Why it matters | Priority | Owner | Status |
| --- | --- | --- | --- | --- | --- |
| I-001 | S3-compatible backup storage | Keeps backups off-host | Later | — | Idea |
| I-002 | Compose stack management | Groups related containers into applications | Later | — | Idea |
| I-003 | Notifications for engine/backup failures | Reduces time to detection | Later | — | Idea |
## 16. Change log
| Date | Change |
| --- | --- |
| 2026-08-06 | Initial project plan created. |

51
cmd/faro-server/main.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"faro/internal/api"
"github.com/moby/moby/client"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
logger.Error("create local Docker client", "error", err)
os.Exit(1)
}
defer dockerClient.Close()
server := &http.Server{
Addr: ":8080",
Handler: api.NewHandler(dockerClient, logger),
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
logger.Info("Faro API listening", "address", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("serve API", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("shut down API", "error", err)
}
}

172
cmd/faro/main.go Normal file
View File

@ -0,0 +1,172 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
)
const defaultServerURL = "http://localhost:8080"
func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
if len(args) == 0 {
printUsage(stderr)
return errors.New("a command is required")
}
switch args[0] {
case "containers":
return runContainers(ctx, args[1:], stdout, stderr)
case "help", "-h", "--help":
printUsage(stdout)
return nil
default:
printUsage(stderr)
return fmt.Errorf("unknown command %q", args[0])
}
}
func runContainers(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("containers", flag.ContinueOnError)
flags.SetOutput(stderr)
engineID := flags.Int("engine", 1, "Docker Engine ID")
serverURL := flags.String("server", defaultServerURL, "Faro server URL")
output := flags.String("output", "table", "output format: table or json")
flags.Usage = func() {
fmt.Fprintln(stderr, "Usage: faro containers [options]")
flags.PrintDefaults()
}
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
}
if *engineID < 1 {
return errors.New("engine must be a positive integer")
}
if *output != "table" && *output != "json" {
return errors.New("output must be table or json")
}
response, err := listContainers(ctx, &http.Client{Timeout: 30 * time.Second}, *serverURL, *engineID)
if err != nil {
return err
}
if *output == "json" {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(response)
}
printContainers(stdout, response.Containers)
return nil
}
type httpClient interface {
Do(request *http.Request) (*http.Response, error)
}
func listContainers(ctx context.Context, client httpClient, serverURL string, engineID int) (listContainersResponse, error) {
baseURL, err := url.Parse(serverURL)
if err != nil {
return listContainersResponse{}, fmt.Errorf("parse server URL: %w", err)
}
if baseURL.Scheme != "http" && baseURL.Scheme != "https" {
return listContainersResponse{}, errors.New("server URL must use http or https")
}
baseURL.Path = strings.TrimRight(baseURL.Path, "/") + "/api/v1/engines/" + strconv.Itoa(engineID) + "/containers"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), nil)
if err != nil {
return listContainersResponse{}, fmt.Errorf("create request: %w", err)
}
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return listContainersResponse{}, fmt.Errorf("contact Faro server: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
var apiResponse errorResponse
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&apiResponse); err == nil && apiResponse.Error.Message != "" {
return listContainersResponse{}, fmt.Errorf("server returned %s: %s", response.Status, apiResponse.Error.Message)
}
return listContainersResponse{}, fmt.Errorf("server returned %s", response.Status)
}
var result listContainersResponse
if err := json.NewDecoder(io.LimitReader(response.Body, 10<<20)).Decode(&result); err != nil {
return listContainersResponse{}, fmt.Errorf("decode response: %w", err)
}
return result, nil
}
type listContainersResponse struct {
Containers []containerResponse `json:"containers"`
}
type containerResponse struct {
ID string `json:"id"`
Names []string `json:"names"`
Image string `json:"image"`
ImageID string `json:"imageId"`
Command string `json:"command"`
Created int64 `json:"created"`
State string `json:"state"`
Status string `json:"status"`
Labels map[string]string `json:"labels"`
}
type errorResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func printContainers(output io.Writer, containers []containerResponse) {
writer := tabwriter.NewWriter(output, 0, 4, 2, ' ', 0)
fmt.Fprintln(writer, "ID\tNAME\tIMAGE\tSTATE\tSTATUS")
for _, item := range containers {
id := item.ID
if len(id) > 12 {
id = id[:12]
}
names := make([]string, len(item.Names))
for index, name := range item.Names {
names[index] = strings.TrimPrefix(name, "/")
}
fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", id, strings.Join(names, ","), item.Image, item.State, item.Status)
}
_ = writer.Flush()
}
func printUsage(output io.Writer) {
fmt.Fprintln(output, `Usage: faro <command> [options]
Commands:
containers List containers for a Docker Engine
Run "faro <command> -h" for command options.`)
}

65
cmd/faro/main_test.go Normal file
View File

@ -0,0 +1,65 @@
package main
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (function roundTripFunc) Do(request *http.Request) (*http.Response, error) {
return function(request)
}
func TestListContainers(t *testing.T) {
client := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if got, want := request.URL.String(), "http://localhost:8080/api/v1/engines/1/containers"; got != want {
t.Errorf("request URL = %q, want %q", got, want)
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader(`{"containers":[{"id":"abc123","names":["/web"],"image":"nginx","state":"running","status":"Up 1 minute"}]}`)),
}, nil
})
result, err := listContainers(context.Background(), client, defaultServerURL, 1)
if err != nil {
t.Fatal(err)
}
if len(result.Containers) != 1 || result.Containers[0].ID != "abc123" {
t.Fatalf("unexpected containers: %#v", result.Containers)
}
}
func TestListContainersReturnsAPIError(t *testing.T) {
client := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNotFound,
Status: "404 Not Found",
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"engine_not_found","message":"engine not found"}}`)),
}, nil
})
_, err := listContainers(context.Background(), client, defaultServerURL, 2)
if err == nil || !strings.Contains(err.Error(), "engine not found") {
t.Fatalf("error = %v, want engine-not-found message", err)
}
}
func TestRunContainersPrintsTable(t *testing.T) {
var output bytes.Buffer
printContainers(&output, []containerResponse{{
ID: "1234567890abcdef", Names: []string{"/web"}, Image: "nginx", State: "running", Status: "Up",
}})
for _, expected := range []string{"1234567890ab", "web", "nginx", "running"} {
if !strings.Contains(output.String(), expected) {
t.Errorf("output %q does not contain %q", output.String(), expected)
}
}
}

27
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

5
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"vue.volar"
]
}

11
frontend/AGENTS.md Normal file
View File

@ -0,0 +1,11 @@
# Project Rules
## General
- Follow the existing code style and patterns.
- Use npm for running project commands.
- Keep code in TypeScript unless migration is required.
## Stack
- Framework: Vue 3 + Vite
- UI Library: Vuetify0
- Enabled Features: ESLint, Pinia, Vue I18n, Vuetify MCP, Vue Router, UnoCSS

93
frontend/README.md Normal file
View File

@ -0,0 +1,93 @@
# frontend
Scaffolded with Vuetify CLI.
## ❗️ Documentation
- Primary docs: https://0.vuetifyjs.com/
- Getting started guide: https://0.vuetifyjs.com/guide
- Community support: https://community.vuetifyjs.com/
- Issue tracker: https://issues.vuetifyjs.com/
## 🧱 Stack
- Framework: Vue 3 + Vite
- UI Library: Vuetify0
- Language: TypeScript
- Package manager: npm
## 🧭 Start Here
- Main entry: `src/main.ts`
- Main app component: `src/App.vue`
- Main styles: `src/styles/`
- Plugin setup: `src/plugins/`
## 📁 Project Structure
- `src/main.ts` — application entry point
- `src/App.vue` — root component
- `src/components/` — reusable Vue components
- `src/plugins/` — plugin registration and setup
- `src/styles/` — global styles and theme settings
- `public/` — static public files
## ✨ Enabled Features
- ESLint
- Pinia
- Vue I18n
- Vuetify MCP
- Vue Router
- UnoCSS
## 💿 Install
Use your selected package manager (npm) to install dependencies:
```bash
npm install
```
## 🚀 Quick Start
```bash
npm install
npm run dev
```
## 🏗️ Build
```bash
npm run build
```
## 🧪 Available Scripts
- `npm run dev`
- `npm run build`
- `npm run preview`
- `npm run build-only`
- `npm run type-check`
- `npm run lint`
- `npm run lint:fix`
## 🤖 Vuetify MCP Server
This project is configured with the Vuetify Model Context Protocol (MCP) server.
To install and configure the MCP server for your favorite IDE (Cursor, Trae, Windsurf, VS Code, Claude Desktop, etc.) run:
```bash
npx -y @vuetify/mcp-cli
```
This will open an interactive setup wizard to help you connect your AI assistant to the Vuetify ecosystem.
## 💪 Support Vuetify Development
This project uses Vuetify0 - an MIT licensed Open Source project. We are glad to welcome contributors and any support for ongoing development:
- Contribute to Vuetify and ecosystem projects: https://github.com/vuetifyjs
- Request enterprise support: https://support.vuetifyjs.com/
- Sponsor on GitHub: https://github.com/sponsors/vuetifyjs
- Support on Open Collective: https://opencollective.com/vuetify

2
frontend/env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-vue-layouts-next/client" />

View File

@ -0,0 +1,5 @@
import vuetify from 'eslint-config-vuetify'
export default vuetify({
ts: true,
})

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/0.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vuetify0 - Headless Components for Vue 3</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

8394
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint",
"lint:fix": "eslint --fix",
"test:e2e": "playwright test"
},
"dependencies": {
"@fontsource/roboto": "^5.2.10",
"@vuetify/v0": "^1.0.0-beta.1",
"pinia": "^3.0.4",
"vue": "^3.5.30",
"vue-i18n": "^11.3.0",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@tsconfig/node22": "^22.0.5",
"@types/node": "^24.12.0",
"@unocss/transformer-directives": "^66.6.6",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"@vuetify/mcp": "^0.5.0",
"eslint": "^9.39.4",
"eslint-config-vuetify": "^4.3.4",
"npm-run-all2": "^8.0.4",
"typescript": "~5.9.3",
"unocss": "^66.6.6",
"unplugin-fonts": "^2.0.0",
"vite": "^8.0.0",
"vue-tsc": "^3.2.5"
}
}

View File

@ -0,0 +1,17 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
})

BIN
frontend/public/0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

137
frontend/src/App.vue Normal file
View File

@ -0,0 +1,137 @@
<script lang="ts" setup>
import { ref } from 'vue'
const drawerOpen = ref(false)
</script>
<template>
<div aria-hidden="true" class="mesh-bg" />
<div class="app-shell min-h-screen">
<header class="app-header border-b border-subtle bg-surface">
<button
aria-label="Toggle navigation"
class="menu-button rounded-lg p-2 hover:bg-surface-tint"
type="button"
@click="drawerOpen = !drawerOpen"
>
<span aria-hidden="true"></span>
</button>
<router-link class="text-xl font-bold text-on-surface no-underline" to="/">
Faro
</router-link>
</header>
<aside class="navigation-drawer border-r border-subtle bg-surface" :class="{ 'drawer-open': drawerOpen }">
<nav aria-label="Main navigation" class="p-4">
<p class="px-3 mb-3 text-xs font-medium uppercase tracking-wide text-on-surface opacity-50">
Navigation
</p>
<router-link class="nav-link" to="/" @click="drawerOpen = false">
Overview
</router-link>
<router-link class="nav-link" to="/containers" @click="drawerOpen = false">
Containers
</router-link>
</nav>
</aside>
<main class="main-bg page-content p-4 md:p-8">
<router-view />
</main>
</div>
</template>
<style>
.app-shell {
display: grid;
grid-template: 4rem 1fr / 15rem 1fr;
}
.app-header {
z-index: 20;
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1.25rem;
}
.menu-button {
display: none;
}
.navigation-drawer {
z-index: 10;
grid-row: 2;
}
.nav-link {
display: block;
padding: 0.75rem;
border-radius: 0.5rem;
color: var(--v0-on-surface);
text-decoration: none;
}
.nav-link:hover {
background: var(--v0-surface-tint);
}
.nav-link.router-link-exact-active {
color: var(--v0-on-primary);
background: var(--v0-primary);
}
.page-content {
min-width: 0;
}
.main-bg {
background: color-mix(in srgb, var(--v0-background) 85%, transparent);
}
.mesh-bg {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(at 40% 20%, color-mix(in srgb, var(--v0-primary) 40%, transparent) 0px, transparent 50%),
radial-gradient(at 80% 0%, color-mix(in srgb, var(--v0-info) 35%, transparent) 0px, transparent 50%),
radial-gradient(at 0% 50%, color-mix(in srgb, var(--v0-error) 25%, transparent) 0px, transparent 50%),
radial-gradient(at 80% 50%, color-mix(in srgb, var(--v0-success) 30%, transparent) 0px, transparent 50%),
radial-gradient(at 20% 80%, color-mix(in srgb, var(--v0-warning) 20%, transparent) 0px, transparent 50%);
}
@media (max-width: 767px) {
.app-shell {
grid-template: 4rem 1fr / 1fr;
}
.menu-button {
display: block;
}
.navigation-drawer {
position: fixed;
top: 4rem;
bottom: 0;
left: 0;
width: 15rem;
transform: translateX(-100%);
transition: transform 160ms ease;
}
.navigation-drawer.drawer-open {
transform: translateX(0);
}
.page-content {
grid-row: 2;
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import { Selection } from '@vuetify/v0'
import { ref } from 'vue'
const components = [
{ id: 'selection', label: 'Selection' },
{ id: 'dialog', label: 'Dialog' },
{ id: 'popover', label: 'Popover' },
]
const selected = ref<string[]>(['selection'])
const links = [
{ label: 'Getting Started', href: 'https://0.vuetifyjs.com/introduction/getting-started' },
{ label: 'Guide', href: 'https://0.vuetifyjs.com/guide' },
{ label: 'Components', href: 'https://0.vuetifyjs.com/components' },
{ label: 'Composables', href: 'https://0.vuetifyjs.com/composables' },
{ label: 'MCP Server', href: 'https://0.vuetifyjs.com/guide/vuetify-mcp' },
]
</script>
<template>
<div class="max-w-2xl mx-auto justify-self-center">
<!-- Hero -->
<div class="text-center mb-12">
<img
alt="Vuetify0 logo"
class="mx-auto mb-4"
height="80"
src="https://cdn.vuetifyjs.com/docs/images/one/logos/vzero.svg"
width="92"
>
<h1 class="text-4xl font-bold text-on-background mb-3">
Vuetify0
</h1>
<p class="text-on-background opacity-60">
Headless components for Vue 3
</p>
</div>
<!-- Links -->
<div class="flex flex-wrap justify-center items-center gap-x-2 gap-y-1 mb-8">
<template v-for="(link, index) in links" :key="link.label">
<a
class="text-sm text-primary hover:underline"
:href="link.href"
rel="noopener noreferrer"
target="_blank"
>
{{ link.label }}<span class="sr-only"> (opens in new tab)</span>
</a>
<span v-if="index < links.length - 1" class="text-on-surface opacity-30"></span>
</template>
</div>
<!-- Interactive Demo -->
<div class="rounded-xl border border-subtle bg-surface p-6">
<div class="text-xs font-medium text-on-surface opacity-50 uppercase tracking-wide mb-4">
Live Demo Selection
</div>
<Selection.Root v-slot="{ attrs }" v-model="selected" multiple>
<div aria-label="Select components" v-bind="attrs" class="flex gap-3 mb-4">
<Selection.Item
v-for="item in components"
:key="item.id"
v-slot="{ isSelected, toggle }"
:value="item.id"
>
<button
:aria-selected="isSelected"
class="flex-1 px-4 py-3 rounded-lg border font-medium transition-colors"
:class="isSelected
? 'bg-primary text-on-primary border-primary'
: 'bg-surface-tint hover:bg-surface-variant border-subtle'"
role="option"
type="button"
@click="toggle"
@keydown.enter.prevent="toggle"
@keydown.space.prevent="toggle"
>
{{ item.label }}
</button>
</Selection.Item>
</div>
<p aria-live="polite" class="text-sm text-on-surface opacity-50" role="status">
Selected: <span class="font-mono">{{ selected.length > 0 ? selected.join(', ') : 'none' }}</span>
</p>
</Selection.Root>
</div>
</div>
</template>

View File

@ -0,0 +1,35 @@
# Components
Vue template files in this folder are automatically imported.
## 🚀 Usage
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
The following example assumes a component located at `src/components/MyComponent.vue`:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
//
</script>
```
When your template is rendered, the component's import will automatically be inlined, which renders to this:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
</script>
```

24
frontend/src/main.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* main.ts
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Composables
import { createApp } from 'vue'
// Plugins
import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
// Styles
import 'virtual:uno.css'
import 'unfonts.css'
const app = createApp(App)
registerPlugins(app)
app.mount('#app')

View File

@ -0,0 +1,93 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
interface Container {
id: string
names: string[]
image: string
state: string
status: string
}
interface ContainersResponse {
containers: Container[]
}
const containers = ref<Container[]>([])
const error = ref('')
const loading = ref(true)
function displayName (container: Container) {
return container.names[0]?.replace(/^\//, '') || container.id.slice(0, 12)
}
async function loadContainers () {
loading.value = true
error.value = ''
try {
const response = await fetch('/api/v1/engines/1/containers')
if (!response.ok) throw new Error(`Request failed with status ${response.status}`)
const data = await response.json() as ContainersResponse
containers.value = data.containers
} catch {
error.value = 'Containers could not be loaded. Check the engine connection and try again.'
} finally {
loading.value = false
}
}
onMounted(loadContainers)
</script>
<template>
<section class="max-w-6xl mx-auto">
<div class="flex items-start justify-between gap-4 mb-8">
<div>
<p class="text-sm font-medium text-primary mb-2">Local engine</p>
<h1 class="text-4xl font-bold text-on-background">Containers</h1>
</div>
<button class="rounded-lg bg-primary px-4 py-2 text-on-primary font-medium" type="button" @click="loadContainers">
Refresh
</button>
</div>
<div v-if="loading" aria-live="polite" class="rounded-xl border border-subtle bg-surface p-8 text-on-surface" role="status">
Loading containers
</div>
<div v-else-if="error" class="rounded-xl border border-error bg-surface p-6" role="alert">
<p class="font-medium text-error mb-3">{{ error }}</p>
<button class="text-primary hover:underline" type="button" @click="loadContainers">Try again</button>
</div>
<div v-else-if="containers.length === 0" class="rounded-xl border border-subtle bg-surface p-8 text-center text-on-surface">
<h2 class="text-lg font-medium mb-2">No containers found</h2>
<p class="opacity-60">This Docker Engine does not have any containers yet.</p>
</div>
<div v-else class="overflow-x-auto rounded-xl border border-subtle bg-surface">
<table class="w-full text-left text-on-surface">
<thead class="bg-surface-tint text-sm">
<tr>
<th class="p-4 font-medium">Name</th>
<th class="p-4 font-medium">Image</th>
<th class="p-4 font-medium">State</th>
<th class="p-4 font-medium">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="container in containers" :key="container.id" class="border-t border-subtle">
<td class="p-4 font-medium">{{ displayName(container) }}</td>
<td class="p-4 font-mono text-sm">{{ container.image }}</td>
<td class="p-4"><span class="rounded-full bg-surface-tint px-3 py-1 text-sm capitalize">{{ container.state }}</span></td>
<td class="p-4 opacity-70">{{ container.status }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>

View File

@ -0,0 +1,10 @@
<template>
<section class="max-w-5xl mx-auto">
<p class="text-sm font-medium text-primary mb-2">Docker Engine management</p>
<h1 class="text-4xl font-bold text-on-background mb-4">Overview</h1>
<p class="text-on-background opacity-70">
Use the navigation drawer to inspect the resources managed by Faro.
</p>
</section>
</template>

View File

@ -0,0 +1,3 @@
# Plugins
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.

View File

@ -0,0 +1,21 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
message: {
hello: 'hello world',
},
},
ja: {
message: {
hello: 'こんにちは、世界',
},
},
}
export default createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages,
})

View File

@ -0,0 +1,19 @@
// Types
import type { App } from 'vue'
import { createPinia } from 'pinia'
import router from '../router'
/**
* plugins/index.ts
*
* Automatically included in `./src/main.ts`
*/
import i18n from './i18n'
// Plugins
import vuetify from './vuetify'
export function registerPlugins (app: App) {
app.use(vuetify)
app.use(createPinia())
app.use(i18n)
app.use(router)
}

View File

@ -0,0 +1,58 @@
import { createThemePlugin } from '@vuetify/v0'
export default createThemePlugin({
default: 'dark',
target: 'html',
themes: {
light: {
dark: false,
colors: {
'primary': '#3b82f6',
'secondary': '#64748b',
'error': '#ef4444',
'info': '#1867c0',
'success': '#22c55e',
'warning': '#f59e0b',
'background': '#f5f5f5',
'surface': '#ffffff',
'surface-tint': '#f5f5f5',
'surface-variant': '#eeeeee',
'divider': '#e0e0e0',
'on-primary': '#ffffff',
'on-secondary': '#ffffff',
'on-error': '#ffffff',
'on-info': '#ffffff',
'on-success': '#ffffff',
'on-warning': '#1a1a1a',
'on-background': '#212121',
'on-surface': '#212121',
'on-surface-variant': '#666666',
},
},
dark: {
dark: true,
colors: {
'primary': '#c4b5fd',
'secondary': '#94a3b8',
'error': '#f87171',
'info': '#38bdf8',
'success': '#4ade80',
'warning': '#fb923c',
'background': '#121212',
'surface': '#1a1a1a',
'surface-tint': '#2a2a2a',
'surface-variant': '#1e1e1e',
'divider': '#404040',
'on-primary': '#1a1a1a',
'on-secondary': '#1a1a1a',
'on-error': '#1a1a1a',
'on-info': '#1a1a1a',
'on-success': '#1a1a1a',
'on-warning': '#1a1a1a',
'on-background': '#e0e0e0',
'on-surface': '#e0e0e0',
'on-surface-variant': '#a0a0a0',
},
},
},
})

View File

@ -0,0 +1,26 @@
/**
* router/index.ts
*
* Manual routes for ./src/pages/*.vue
*/
// Composables
import { createRouter, createWebHistory } from 'vue-router'
import Containers from '@/pages/containers.vue'
import Index from '@/pages/index.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
component: Index,
},
{
path: '/containers',
component: Containers,
},
],
})
export default router

View File

@ -0,0 +1,8 @@
// Utilities
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
//
}),
})

View File

@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test'
test('navigates to and lists containers', async ({ page }) => {
await page.route('**/api/v1/engines/1/containers', route => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
containers: [
{ id: 'abc123', names: ['/web'], image: 'nginx:latest', state: 'running', status: 'Up 2 minutes' },
],
}),
}))
await page.goto('/')
await page.getByRole('link', { name: 'Containers' }).click()
await expect(page).toHaveURL(/\/containers$/)
await expect(page.getByRole('heading', { name: 'Containers' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'web' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'nginx:latest' })).toBeVisible()
})
test('shows an actionable API error', async ({ page }) => {
await page.route('**/api/v1/engines/1/containers', route => route.fulfill({ status: 502 }))
await page.goto('/containers')
await expect(page.getByRole('alert')).toContainText('Containers could not be loaded')
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible()
})

View File

@ -0,0 +1,26 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": [
"src/**/__tests__/*"
],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": [
"./src/*"
]
}
},
"vueCompilerOptions": {
"plugins": [
"vue-router/volar/sfc-typed-router",
"vue-router/volar/sfc-route-blocks"
]
}
}

11
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

67
frontend/unocss.config.ts Normal file
View File

@ -0,0 +1,67 @@
import { defineConfig, presetWind4 } from 'unocss'
export default defineConfig({
presets: [
presetWind4(),
],
// Wind4 uses color-mix with oklch - opacity modifiers (bg-surface/50)
// don't work with CSS variables. Use color-mix shortcuts instead.
shortcuts: {
'bg-glass-surface': '[background:color-mix(in_srgb,var(--v0-surface)_70%,transparent)] backdrop-blur-12',
'border-subtle': '[border-color:color-mix(in_srgb,var(--v0-divider)_50%,transparent)]',
'sr-only': 'absolute w-1px h-1px p-0 -m-1px overflow-hidden whitespace-nowrap border-0',
},
preflights: [
{
getCSS: () => `
html {
scrollbar-gutter: stable;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
*:focus-visible {
outline: 2px solid var(--v0-primary);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
`,
},
],
theme: {
colors: {
'primary': 'var(--v0-primary)',
'secondary': 'var(--v0-secondary)',
'error': 'var(--v0-error)',
'info': 'var(--v0-info)',
'success': 'var(--v0-success)',
'warning': 'var(--v0-warning)',
'background': 'var(--v0-background)',
'surface': 'var(--v0-surface)',
'surface-tint': 'var(--v0-surface-tint)',
'surface-variant': 'var(--v0-surface-variant)',
'divider': 'var(--v0-divider)',
'on-primary': 'var(--v0-on-primary)',
'on-secondary': 'var(--v0-on-secondary)',
'on-error': 'var(--v0-on-error)',
'on-info': 'var(--v0-on-info)',
'on-success': 'var(--v0-on-success)',
'on-warning': 'var(--v0-on-warning)',
'on-background': 'var(--v0-on-background)',
'on-surface': 'var(--v0-on-surface)',
'on-surface-variant': 'var(--v0-on-surface-variant)',
},
},
})

31
frontend/vite.config.mts Normal file
View File

@ -0,0 +1,31 @@
import { fileURLToPath, URL } from 'node:url'
import Vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
import Fonts from 'unplugin-fonts/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [Vue(), Fonts({
fontsource: {
families: [
{
name: 'Roboto',
weights: [100, 300, 400, 500, 700, 900],
styles: ['normal', 'italic'],
},
],
},
}), UnoCSS()],
resolve: {
alias: {
'@': fileURLToPath(new URL('src', import.meta.url)),
},
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
},
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:8080',
},
},
})

29
go.mod Normal file
View File

@ -0,0 +1,29 @@
module faro
go 1.26
require (
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/sys v0.33.0 // indirect
)

61
go.sum Normal file
View File

@ -0,0 +1,61 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

98
internal/api/api.go Normal file
View File

@ -0,0 +1,98 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
)
// ContainerLister is the subset of the Docker client used by the API.
type ContainerLister interface {
ContainerList(ctx context.Context, options client.ContainerListOptions) (client.ContainerListResult, error)
}
type API struct {
localEngine ContainerLister
logger *slog.Logger
}
func NewHandler(localEngine ContainerLister, logger *slog.Logger) http.Handler {
api := &API{localEngine: localEngine, logger: logger}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/engines/{engineID}/containers", api.listContainers)
return mux
}
func (a *API) listContainers(w http.ResponseWriter, r *http.Request) {
if r.PathValue("engineID") != "1" {
writeError(w, http.StatusNotFound, "engine_not_found", "engine not found")
return
}
result, err := a.localEngine.ContainerList(r.Context(), client.ContainerListOptions{All: true})
if err != nil {
a.logger.Error("list Docker containers", "engine_id", 1, "error", err)
writeError(w, http.StatusBadGateway, "docker_unavailable", "could not list containers from the Docker Engine")
return
}
items := make([]containerResponse, 0, len(result.Items))
for _, item := range result.Items {
items = append(items, newContainerResponse(item))
}
writeJSON(w, http.StatusOK, listContainersResponse{Containers: items})
}
type listContainersResponse struct {
Containers []containerResponse `json:"containers"`
}
type containerResponse struct {
ID string `json:"id"`
Names []string `json:"names"`
Image string `json:"image"`
ImageID string `json:"imageId"`
Command string `json:"command"`
Created int64 `json:"created"`
State string `json:"state"`
Status string `json:"status"`
Labels map[string]string `json:"labels"`
}
func newContainerResponse(item container.Summary) containerResponse {
return containerResponse{
ID: item.ID,
Names: item.Names,
Image: item.Image,
ImageID: item.ImageID,
Command: item.Command,
Created: item.Created,
State: string(item.State),
Status: item.Status,
Labels: item.Labels,
}
}
type errorResponse struct {
Error apiError `json:"error"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Error: apiError{Code: code, Message: message}})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}

76
internal/api/api_test.go Normal file
View File

@ -0,0 +1,76 @@
package api
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
)
type fakeContainerLister struct {
containers []container.Summary
err error
options client.ContainerListOptions
}
func (f *fakeContainerLister) ContainerList(_ context.Context, options client.ContainerListOptions) (client.ContainerListResult, error) {
f.options = options
return client.ContainerListResult{Items: f.containers}, f.err
}
func TestListContainers(t *testing.T) {
docker := &fakeContainerLister{containers: []container.Summary{{
ID: "abc123", Names: []string{"/web"}, Image: "nginx:latest", State: "running",
}}}
request := httptest.NewRequest(http.MethodGet, "/api/v1/engines/1/containers", nil)
response := httptest.NewRecorder()
NewHandler(docker, testLogger()).ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
if !docker.options.All {
t.Error("ContainerList All = false, want true")
}
if body := response.Body.String(); !strings.Contains(body, `"id":"abc123"`) {
t.Errorf("response body does not contain container: %s", body)
}
}
func TestListContainersRejectsUnknownEngine(t *testing.T) {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/engines/2/containers", nil)
NewHandler(&fakeContainerLister{}, testLogger()).ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNotFound)
}
}
func TestListContainersHandlesDockerError(t *testing.T) {
docker := &fakeContainerLister{err: errors.New("daemon unavailable")}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/engines/1/containers", nil)
NewHandler(docker, testLogger()).ServeHTTP(response, request)
if response.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadGateway)
}
if body := response.Body.String(); !strings.Contains(body, `"code":"docker_unavailable"`) {
t.Errorf("unexpected response body: %s", body)
}
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}