first commit
This commit is contained in:
commit
e75ff83f19
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
9
.idea/faro.iml
generated
Normal 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
6
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
372
PLAN.md
Normal 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
51
cmd/faro-server/main.go
Normal 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
172
cmd/faro/main.go
Normal 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
65
cmd/faro/main_test.go
Normal 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
27
frontend/.gitignore
vendored
Normal 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
5
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar"
|
||||
]
|
||||
}
|
||||
11
frontend/AGENTS.md
Normal file
11
frontend/AGENTS.md
Normal 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
93
frontend/README.md
Normal 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
2
frontend/env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-vue-layouts-next/client" />
|
||||
5
frontend/eslint.config.js
Normal file
5
frontend/eslint.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
import vuetify from 'eslint-config-vuetify'
|
||||
|
||||
export default vuetify({
|
||||
ts: true,
|
||||
})
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
8394
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
frontend/playwright.config.ts
Normal file
17
frontend/playwright.config.ts
Normal 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
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
137
frontend/src/App.vue
Normal 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>
|
||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
6
frontend/src/assets/logo.svg
Normal file
6
frontend/src/assets/logo.svg
Normal 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 |
96
frontend/src/components/HelloWorld.vue
Normal file
96
frontend/src/components/HelloWorld.vue
Normal 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>
|
||||
35
frontend/src/components/README.md
Normal file
35
frontend/src/components/README.md
Normal 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
24
frontend/src/main.ts
Normal 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')
|
||||
93
frontend/src/pages/containers.vue
Normal file
93
frontend/src/pages/containers.vue
Normal 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>
|
||||
10
frontend/src/pages/index.vue
Normal file
10
frontend/src/pages/index.vue
Normal 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>
|
||||
3
frontend/src/plugins/README.md
Normal file
3
frontend/src/plugins/README.md
Normal 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.
|
||||
21
frontend/src/plugins/i18n.ts
Normal file
21
frontend/src/plugins/i18n.ts
Normal 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,
|
||||
})
|
||||
19
frontend/src/plugins/index.ts
Normal file
19
frontend/src/plugins/index.ts
Normal 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)
|
||||
}
|
||||
58
frontend/src/plugins/vuetify.ts
Normal file
58
frontend/src/plugins/vuetify.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
26
frontend/src/router/index.ts
Normal file
26
frontend/src/router/index.ts
Normal 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
|
||||
8
frontend/src/stores/app.ts
Normal file
8
frontend/src/stores/app.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// Utilities
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
//
|
||||
}),
|
||||
})
|
||||
29
frontend/tests/containers.spec.ts
Normal file
29
frontend/tests/containers.spec.ts
Normal 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()
|
||||
})
|
||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal 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
11
frontend/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
frontend/tsconfig.node.json
Normal file
19
frontend/tsconfig.node.json
Normal 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
67
frontend/unocss.config.ts
Normal 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
31
frontend/vite.config.mts
Normal 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
29
go.mod
Normal 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
61
go.sum
Normal 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
98
internal/api/api.go
Normal 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
76
internal/api/api_test.go
Normal 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))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user