feat(engine): podman native — auto-detect runtime (docker→podman), compose mengikuti engine, doctor engine-agnostic
CI / test (push) Successful in 25s
CI / test (push) Successful in 25s
This commit is contained in:
1 parent
1b9a406aa7
commit
0551fb1387
12 files changed
+292
-60
No files matched your search
+1
-1
@@ -266,7 +266,7 @@ func TestDoctorCommand(t *testing.T) {
|
||||
t.Fatalf("exit code %d, mau 0/1.\n%s", code, out)
|
||||
}
|
||||
s := string(out)
|
||||
for _, want := range []string{"docker", "audio", "go", "compose", "stacks"} {
|
||||
for _, want := range []string{"engine", "audio", "go", "compose", "stacks"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("output doctor tidak memuat %q:\n%s", want, s)
|
||||
}
|
||||
|
||||
+17
-14
@@ -147,6 +147,8 @@ type Model struct {
|
||||
compose *docker.ComposeClient
|
||||
engine docker.Info
|
||||
|
||||
engineName string
|
||||
|
||||
stacksDir string
|
||||
stacks []stack.Stack
|
||||
stacksID int64
|
||||
@@ -178,7 +180,7 @@ func New() Model {
|
||||
i18n.SetLang(cfg.UI.Language)
|
||||
theme.Init(theme.Mode(cfg.UI.ThemeMode), cfg.UI.ThemeAccent)
|
||||
|
||||
cl, err := docker.New()
|
||||
cl, engineInfo, err := docker.Detect(cfg.Docker.Host)
|
||||
host, _ := os.Hostname()
|
||||
m := Model{
|
||||
sound: audio.New(audio.Options{
|
||||
@@ -186,19 +188,20 @@ func New() Model {
|
||||
Volume: cfg.UI.SoundVolume,
|
||||
MuteOnSSH: cfg.UI.SoundMuteOnSSH,
|
||||
}),
|
||||
tickID: animation.NewID(),
|
||||
animating: cfg.UI.AnimationEnabled,
|
||||
toast: components.NewToast(),
|
||||
view: viewHome,
|
||||
section: sectionStacks,
|
||||
focus: focusSidebar,
|
||||
tab: tabLogs,
|
||||
hostname: host,
|
||||
sys: sysinfo.Read(),
|
||||
cl: cl,
|
||||
compose: docker.NewCompose(cfg.Docker.ComposeCommand),
|
||||
stacksDir: config.ExpandPath(cfg.StacksDir),
|
||||
loading: true,
|
||||
tickID: animation.NewID(),
|
||||
animating: cfg.UI.AnimationEnabled,
|
||||
toast: components.NewToast(),
|
||||
view: viewHome,
|
||||
section: sectionStacks,
|
||||
focus: focusSidebar,
|
||||
tab: tabLogs,
|
||||
hostname: host,
|
||||
sys: sysinfo.Read(),
|
||||
cl: cl,
|
||||
engineName: engineInfo.Name,
|
||||
compose: docker.NewCompose(cfg.Docker.ComposeCommand, engineInfo.Name),
|
||||
stacksDir: config.ExpandPath(cfg.StacksDir),
|
||||
loading: true,
|
||||
|
||||
tailLines: cfg.UI.LogTailLines,
|
||||
showTimestamps: cfg.UI.ShowTimestamps,
|
||||
|
||||
@@ -63,6 +63,14 @@ func (m Model) clockPanel(w, h int) string {
|
||||
return panel(i18n.T("section.clock"), "", lines, w, h, false)
|
||||
}
|
||||
|
||||
// engineTitle — judul panel runtime (Docker/Podman) sesuai hasil deteksi.
|
||||
func (m Model) engineTitle() string {
|
||||
if m.engineName == "podman" {
|
||||
return "Podman"
|
||||
}
|
||||
return "Docker"
|
||||
}
|
||||
|
||||
func (m Model) dockerPanel(w, h int) string {
|
||||
var lines []string
|
||||
switch {
|
||||
@@ -80,7 +88,7 @@ func (m Model) dockerPanel(w, h int) string {
|
||||
styled(theme.Current().Success, i18n.T("state.connected")),
|
||||
)
|
||||
}
|
||||
return panel("Docker", i18n.T("host.local"), lines, w, h, false)
|
||||
return panel(m.engineTitle(), i18n.T("host.local"), lines, w, h, false)
|
||||
}
|
||||
|
||||
func (m Model) hostPanel(w, h int) string {
|
||||
|
||||
+20
-17
@@ -25,24 +25,30 @@ func Doctor(w io.Writer) int {
|
||||
|
||||
fail := 0
|
||||
|
||||
// Docker — dependency inti.
|
||||
switch cl, err := docker.New(); {
|
||||
// Konfigurasi dimuat lebih dulu — host & compose dipakai di bawah.
|
||||
cfg, cfgErr := config.Load()
|
||||
|
||||
// Engine Docker/Podman — dependency inti (auto-detect bila host kosong).
|
||||
engineName := ""
|
||||
switch cl, engine, err := docker.Detect(cfg.Docker.Host); {
|
||||
case err != nil:
|
||||
fmt.Fprintf(w, "docker : %s %v\n", i18n.T("error.fail"), err)
|
||||
fmt.Fprintf(w, "engine : %s %v\n", i18n.T("error.fail"), err)
|
||||
fail++
|
||||
default:
|
||||
defer cl.Close()
|
||||
if err := cl.Ping(ctx); err != nil {
|
||||
fmt.Fprintf(w, "docker : %s %s\n", i18n.T("error.fail"),
|
||||
i18n.Tf("doctor.dockerDaemonUnreachable", err))
|
||||
fail++
|
||||
} else if info, err := cl.Info(ctx); err != nil {
|
||||
fmt.Fprintf(w, "docker : %s %v\n", i18n.T("error.warn"), err)
|
||||
engineName = engine.Name
|
||||
host := engine.Host
|
||||
if host == "" {
|
||||
host = i18n.T("doctor.hostDefault")
|
||||
}
|
||||
if info, err := cl.Info(ctx); err != nil {
|
||||
fmt.Fprintf(w, "engine : %s %s %v\n", i18n.T("error.warn"), engine.Name, err)
|
||||
} else {
|
||||
fmt.Fprintf(w, "docker : %s %s\n", i18n.T("error.ok"),
|
||||
i18n.Tf("doctor.dockerOk", info.ServerVersion, info.APIVersion,
|
||||
fmt.Fprintf(w, "engine : %s %s\n", i18n.T("error.ok"),
|
||||
i18n.Tf("doctor.engineOk", engine.Name, info.ServerVersion, info.APIVersion,
|
||||
info.Running, info.Containers, info.Images))
|
||||
}
|
||||
fmt.Fprintf(w, "socket : %s\n", host)
|
||||
}
|
||||
|
||||
// Audio — opsional; engine init async, tunggu maks 2 dtk.
|
||||
@@ -52,15 +58,12 @@ func Doctor(w io.Writer) int {
|
||||
|
||||
fmt.Fprintf(w, "go : %s (%s/%s)\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
// Konfigurasi (dipakai compose, stack, dan output config di bawah).
|
||||
cfg, cfgErr := config.Load()
|
||||
|
||||
// Compose — dipakai operasi stack (up/down/pull).
|
||||
compose := docker.NewCompose(cfg.Docker.ComposeCommand)
|
||||
// Compose — dipakai operasi stack (up/down/pull); "auto" mengikuti engine.
|
||||
compose := docker.NewCompose(cfg.Docker.ComposeCommand, engineName)
|
||||
if ver, err := compose.Version(ctx); err != nil {
|
||||
fmt.Fprintf(w, "compose : %s %v\n", i18n.T("error.warn"), err)
|
||||
} else {
|
||||
fmt.Fprintf(w, "compose : %s %s\n", i18n.T("error.ok"), i18n.Tf("doctor.composeOk", ver, cfg.Docker.ComposeCommand))
|
||||
fmt.Fprintf(w, "compose : %s %s\n", i18n.T("error.ok"), i18n.Tf("doctor.composeOk", ver, compose.Command()))
|
||||
}
|
||||
|
||||
cfgPath := config.Path()
|
||||
|
||||
@@ -21,9 +21,10 @@ type Config struct {
|
||||
Docker DockerCfg `yaml:"docker"`
|
||||
}
|
||||
|
||||
// DockerCfg — pengaturan interaksi Docker.
|
||||
// DockerCfg — pengaturan interaksi runtime (Docker ATAU Podman).
|
||||
type DockerCfg struct {
|
||||
ComposeCommand string `yaml:"compose_command"` // "docker compose" atau "docker-compose"
|
||||
Host string `yaml:"host"` // kosong = auto-detect (docker → podman)
|
||||
ComposeCommand string `yaml:"compose_command"` // "auto" | "docker compose" | "podman compose" | …
|
||||
}
|
||||
|
||||
type UI struct {
|
||||
@@ -54,7 +55,7 @@ func Default() Config {
|
||||
LogTailLines: 300,
|
||||
ShowTimestamps: true,
|
||||
},
|
||||
Docker: DockerCfg{ComposeCommand: "docker compose"},
|
||||
Docker: DockerCfg{ComposeCommand: "auto"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +145,7 @@ func (c *Config) normalize() {
|
||||
c.Version = 1
|
||||
}
|
||||
if c.Docker.ComposeCommand == "" {
|
||||
c.Docker.ComposeCommand = "docker compose"
|
||||
c.Docker.ComposeCommand = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,20 +17,11 @@ import (
|
||||
)
|
||||
|
||||
// Client — pembungkus Docker SDK dengan negosiasi versi API
|
||||
// (mendukung Docker Engine 20.10+, PRD §9).
|
||||
// (mendukung Docker Engine 20.10+ DAN Podman dengan API kompatibel-Docker).
|
||||
type Client struct {
|
||||
cli *client.Client
|
||||
}
|
||||
|
||||
// New membaca konfigurasi dari env (DOCKER_HOST dll) atau default socket.
|
||||
func New() (*Client, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: create client: %w", err)
|
||||
}
|
||||
return &Client{cli: cli}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error { return c.cli.Close() }
|
||||
|
||||
// Info — ringkasan daemon untuk dashboard, doctor, dan status bar.
|
||||
|
||||
@@ -14,14 +14,44 @@ type ComposeClient struct {
|
||||
cmd []string // mis. ["docker","compose"] atau ["docker-compose"]
|
||||
}
|
||||
|
||||
func NewCompose(composeCmd string) *ComposeClient {
|
||||
fields := strings.Fields(composeCmd)
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"docker", "compose"}
|
||||
}
|
||||
return &ComposeClient{cmd: fields}
|
||||
// NewCompose — spec "auto" memilih perintah sesuai engine & ketersediaan CLI;
|
||||
// spec eksplisit (mis. "docker compose") selalu menang.
|
||||
func NewCompose(spec, engine string) *ComposeClient {
|
||||
return &ComposeClient{cmd: pickCompose(spec, engine, exec.LookPath)}
|
||||
}
|
||||
|
||||
// pickCompose — dipisah agar lookPath dapat di-stub di unit test.
|
||||
func pickCompose(spec, engine string, lookPath func(string) (string, error)) []string {
|
||||
if spec != "" && spec != "auto" {
|
||||
if f := strings.Fields(spec); len(f) > 0 {
|
||||
return f
|
||||
}
|
||||
}
|
||||
orders := [][]string{
|
||||
{"docker", "compose"},
|
||||
{"docker-compose"},
|
||||
{"podman", "compose"},
|
||||
{"podman-compose"},
|
||||
}
|
||||
if engine == "podman" {
|
||||
orders = [][]string{
|
||||
{"podman", "compose"},
|
||||
{"podman-compose"},
|
||||
{"docker", "compose"},
|
||||
{"docker-compose"},
|
||||
}
|
||||
}
|
||||
for _, o := range orders {
|
||||
if _, err := lookPath(o[0]); err == nil {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return []string{"docker", "compose"} // tidak ada → error jelas saat dijalankan
|
||||
}
|
||||
|
||||
// Command — perintah compose yang benar-benar dipakai (untuk doctor/log).
|
||||
func (c *ComposeClient) Command() string { return strings.Join(c.cmd, " ") }
|
||||
|
||||
// ComposeProject — entri `docker compose ls --all --format json`
|
||||
// (format terverifikasi: {"Name","Status":"running(N)","ConfigFiles":"a,b"}).
|
||||
type ComposeProject struct {
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestIntegrationCompose(t *testing.T) {
|
||||
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
|
||||
t.Skip("docker socket tidak ada")
|
||||
}
|
||||
c := NewCompose("docker compose")
|
||||
c := NewCompose("docker compose", "docker")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// EngineInfo — runtime yang benar-benar terdeteksi (Docker ATAU Podman).
|
||||
type EngineInfo struct {
|
||||
Name string // "docker" | "podman"
|
||||
Host string // unix:///… atau tcp://… (kosong = default/env DOCKER_HOST)
|
||||
}
|
||||
|
||||
// candidate — endpoint yang dicoba berurutan saat deteksi.
|
||||
type candidate struct {
|
||||
name string
|
||||
host string // kosong = biarkan env/DOCKER_HOST yang menentukan
|
||||
}
|
||||
|
||||
// engineCandidates — urutan kandidat: override config → DOCKER_HOST →
|
||||
// docker default → podman rootless (XDG) → podman rootful.
|
||||
func engineCandidates(override string) []candidate {
|
||||
switch {
|
||||
case override != "":
|
||||
return []candidate{{name: nameFromHost(override), host: override}}
|
||||
case os.Getenv("DOCKER_HOST") != "":
|
||||
h := os.Getenv("DOCKER_HOST")
|
||||
return []candidate{{name: nameFromHost(h), host: ""}}
|
||||
}
|
||||
out := []candidate{{name: "docker", host: ""}}
|
||||
if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
|
||||
out = append(out, candidate{
|
||||
name: "podman",
|
||||
host: "unix://" + filepath.Join(xdg, "podman", "podman.sock"),
|
||||
})
|
||||
}
|
||||
out = append(out, candidate{name: "podman", host: "unix:///run/podman/podman.sock"})
|
||||
return out
|
||||
}
|
||||
|
||||
func nameFromHost(host string) string {
|
||||
if strings.Contains(host, "podman") {
|
||||
return "podman"
|
||||
}
|
||||
return "docker"
|
||||
}
|
||||
|
||||
// Detect — client pertama yang ping-nya hidup, plus info runtime-nya.
|
||||
// Podman mengekspos API kompatibel-Docker di podman.sock, jadi client yang
|
||||
// sama dipakai untuk keduanya.
|
||||
func Detect(override string) (*Client, EngineInfo, error) {
|
||||
connect := func(host string) (*Client, error) {
|
||||
opts := []client.Opt{client.WithAPIVersionNegotiation()}
|
||||
if host == "" {
|
||||
opts = append(opts, client.FromEnv)
|
||||
} else {
|
||||
opts = append(opts, client.WithHost(host))
|
||||
}
|
||||
cli, err := client.NewClientWithOpts(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if _, err := cli.Ping(ctx); err != nil {
|
||||
_ = cli.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Client{cli: cli}, nil
|
||||
}
|
||||
return detectWith(engineCandidates(override), connect)
|
||||
}
|
||||
|
||||
// detectWith — inti deteksi; connect dapat di-stub di unit test.
|
||||
func detectWith(cands []candidate, connect func(host string) (*Client, error)) (*Client, EngineInfo, error) {
|
||||
var lastErr error
|
||||
for _, c := range cands {
|
||||
cl, err := connect(c.host)
|
||||
if err == nil {
|
||||
return cl, EngineInfo{Name: c.name, Host: c.host}, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, EngineInfo{}, fmt.Errorf("engine: tidak ada Docker/Podman yang merespons: %w", lastErr)
|
||||
}
|
||||
|
||||
// New — pintasan auto-detect (kompatibilitas; info runtime diabaikan).
|
||||
func New() (*Client, error) {
|
||||
cl, _, err := Detect("")
|
||||
return cl, err
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEngineCandidates(t *testing.T) {
|
||||
t.Setenv("DOCKER_HOST", "")
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000")
|
||||
cands := engineCandidates("")
|
||||
if len(cands) != 3 {
|
||||
t.Fatalf("harusnya 3 kandidat: %+v", cands)
|
||||
}
|
||||
if cands[0].name != "docker" || cands[0].host != "" {
|
||||
t.Fatalf("kandidat pertama harus docker default: %+v", cands[0])
|
||||
}
|
||||
if cands[1].name != "podman" || cands[1].host != "unix:///run/user/1000/podman/podman.sock" {
|
||||
t.Fatalf("kandidat kedua harus podman rootless: %+v", cands[1])
|
||||
}
|
||||
if cands[2].host != "unix:///run/podman/podman.sock" {
|
||||
t.Fatalf("kandidat ketiga harus podman rootful: %+v", cands[2])
|
||||
}
|
||||
|
||||
// override menang dan namanya mengikuti isi host
|
||||
cands = engineCandidates("unix:///run/user/1000/podman/podman.sock")
|
||||
if len(cands) != 1 || cands[0].name != "podman" {
|
||||
t.Fatalf("override podman salah: %+v", cands)
|
||||
}
|
||||
cands = engineCandidates("tcp://192.168.1.5:2375")
|
||||
if len(cands) != 1 || cands[0].name != "docker" {
|
||||
t.Fatalf("override tcp harus docker: %+v", cands)
|
||||
}
|
||||
|
||||
// DOCKER_HOST diset → satu kandidat, host dibiarkan kosong (FromEnv)
|
||||
t.Setenv("DOCKER_HOST", "unix:///custom.sock")
|
||||
cands = engineCandidates("")
|
||||
if len(cands) != 1 || cands[0].host != "" || cands[0].name != "docker" {
|
||||
t.Fatalf("DOCKER_HOST salah: %+v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectWith(t *testing.T) {
|
||||
cands := []candidate{{name: "docker", host: "d1"}, {name: "podman", host: "p1"}}
|
||||
|
||||
// docker mati → jatuh ke podman
|
||||
connect := func(host string) (*Client, error) {
|
||||
if host == "p1" {
|
||||
return &Client{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("connection refused")
|
||||
}
|
||||
cl, info, err := detectWith(cands, connect)
|
||||
if err != nil || cl == nil || info.Name != "podman" || info.Host != "p1" {
|
||||
t.Fatalf("fallback salah: cl=%v info=%+v err=%v", cl, info, err)
|
||||
}
|
||||
|
||||
// semua mati → error jelas
|
||||
connect = func(host string) (*Client, error) { return nil, fmt.Errorf("matilah") }
|
||||
if _, _, err := detectWith(cands, connect); err == nil {
|
||||
t.Fatal("semua gagal harus error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickCompose(t *testing.T) {
|
||||
have := func(bins ...string) func(string) (string, error) {
|
||||
m := map[string]bool{}
|
||||
for _, b := range bins {
|
||||
m[b] = true
|
||||
}
|
||||
return func(b string) (string, error) {
|
||||
if m[b] {
|
||||
return "/usr/bin/" + b, nil
|
||||
}
|
||||
return "", fmt.Errorf("tidak ada")
|
||||
}
|
||||
}
|
||||
|
||||
// podman engine: binary podman ada → `podman compose` (subcommand bawaan)
|
||||
if got := pickCompose("auto", "podman", have("docker", "podman")); got[0] != "podman" || got[1] != "compose" {
|
||||
t.Fatalf("podman engine: %v", got)
|
||||
}
|
||||
// podman engine tanpa binary podman → jatuh ke podman-compose
|
||||
if got := pickCompose("auto", "podman", have("podman-compose")); got[0] != "podman-compose" {
|
||||
t.Fatalf("fallback podman-compose: %v", got)
|
||||
}
|
||||
// engine docker
|
||||
if got := pickCompose("auto", "docker", have("docker")); got[0] != "docker" || got[1] != "compose" {
|
||||
t.Fatalf("docker engine: %v", got)
|
||||
}
|
||||
// docker engine, tapi hanya podman yang ada → tetap pakai podman
|
||||
if got := pickCompose("auto", "docker", have("podman")); got[0] != "podman" {
|
||||
t.Fatalf("degradasi ke podman: %v", got)
|
||||
}
|
||||
// spec eksplisit selalu menang
|
||||
if got := pickCompose("docker-compose", "podman", have("docker-compose")); got[0] != "docker-compose" {
|
||||
t.Fatalf("eksplisit: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ footer:
|
||||
toast:
|
||||
demoTitle: "✔ [ SUCCESS ] Conversion Saved"
|
||||
demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/"
|
||||
dockerError: "✖ [ FAILED ] Docker"
|
||||
dockerError: "✖ [ FAILED ] Engine"
|
||||
configError: "⚠ [ WARNING ] Config problem — using defaults"
|
||||
themeMode: "Theme mode: %s"
|
||||
timestamps: "Log timestamps: %s"
|
||||
@@ -142,8 +142,8 @@ duration:
|
||||
minutes: "%d mins"
|
||||
|
||||
doctor:
|
||||
dockerDaemonUnreachable: "daemon not responding: %v"
|
||||
dockerOk: "engine %s (API %s) · %d/%d containers running · %d images"
|
||||
engineOk: "%s %s (API %s) · %d/%d containers running · %d images"
|
||||
hostDefault: "(default)"
|
||||
stacksMissing: "%s (missing — created during onboarding)"
|
||||
configMissing: "%s (missing — using defaults)"
|
||||
composeOk: "%s · %s"
|
||||
|
||||
@@ -118,7 +118,7 @@ footer:
|
||||
toast:
|
||||
demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan"
|
||||
demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/"
|
||||
dockerError: "✖ [ GAGAL ] Docker"
|
||||
dockerError: "✖ [ GAGAL ] Engine"
|
||||
configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default"
|
||||
themeMode: "Mode tema: %s"
|
||||
timestamps: "Timestamp log: %s"
|
||||
@@ -142,8 +142,8 @@ duration:
|
||||
minutes: "%d mnt"
|
||||
|
||||
doctor:
|
||||
dockerDaemonUnreachable: "daemon tidak merespons: %v"
|
||||
dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image"
|
||||
engineOk: "%s %s (API %s) · %d/%d kontainer berjalan · %d image"
|
||||
hostDefault: "(default)"
|
||||
stacksMissing: "%s (belum ada — dibuat saat onboarding)"
|
||||
configMissing: "%s (belum ada — memakai default)"
|
||||
composeOk: "%s · %s"
|
||||
|
||||
Reference in new issue
Block a user